bfi-cli 0.1.99 → 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.
@@ -0,0 +1,436 @@
1
+ import { mapStrings, normalizeConfig, object, resolveVariables, validName, variablePattern, variables } from "./model.mjs";
2
+ import { identity, readConfig, writeConfig } from "./files.mjs";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { existsSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { applyEdits, getNodeValue, modify, parseTree } from "jsonc-parser";
8
+ import { parse as parse$1, stringify } from "smol-toml";
9
+ const agentIds = [
10
+ "claude-code",
11
+ "codex",
12
+ "cursor",
13
+ "qwen-code",
14
+ "gemini-cli",
15
+ "zcode",
16
+ "codebuddy",
17
+ "kimi-code",
18
+ "opencode"
19
+ ];
20
+ const aliases = {
21
+ claude: "claude-code",
22
+ qwen: "qwen-code",
23
+ gemini: "gemini-cli",
24
+ kimi: "kimi-code",
25
+ "kimi-cli": "kimi-code",
26
+ "codebuddy-code": "codebuddy"
27
+ };
28
+ function preferred(paths) {
29
+ return paths.find((filename) => existsSync(filename)) ?? paths[0];
30
+ }
31
+ function targetFor(name, scope = "user", options = {}) {
32
+ const agent = aliases[name] ?? name;
33
+ if (!agentIds.includes(agent)) throw new Error(`尚未支持该 Agent 的自动配置。支持:${agentIds.join(", ")}。WorkBuddy/IDE 专有配置请使用原生导入,不猜测路径。`);
34
+ const home = options.home ?? homedir();
35
+ const cwd = options.cwd ?? process.cwd();
36
+ const env = options.env ?? process.env;
37
+ const user = scope === "user";
38
+ const paths = {
39
+ "claude-code": [user ? join(home, ".claude.json") : join(cwd, ".mcp.json")],
40
+ codex: [user ? join(env.CODEX_HOME ?? join(home, ".codex"), "config.toml") : join(cwd, ".codex/config.toml")],
41
+ cursor: [join(user ? home : cwd, ".cursor/mcp.json")],
42
+ "qwen-code": [join(user ? home : cwd, ".qwen/settings.json")],
43
+ "gemini-cli": [join(user ? home : cwd, ".gemini/settings.json")],
44
+ zcode: [join(user ? home : cwd, user ? ".zcode/cli/config.json" : ".zcode/config.json")],
45
+ codebuddy: user ? [
46
+ join(home, ".codebuddy/.mcp.json"),
47
+ join(home, ".codebuddy/mcp.json"),
48
+ join(home, ".codebuddy.json")
49
+ ] : [join(cwd, ".mcp.json"), join(cwd, "mcp.json")],
50
+ "kimi-code": [join(user ? env.KIMI_CODE_HOME ?? join(home, ".kimi-code") : join(cwd, ".kimi-code"), "mcp.json")],
51
+ opencode: [join(user ? env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "opencode") : join(home, ".config/opencode") : cwd, "opencode.jsonc"), join(user ? env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "opencode") : join(home, ".config/opencode") : cwd, "opencode.json")]
52
+ };
53
+ if (agent === "claude-code" && env.CLAUDE_CONFIG_DIR && !options.config) throw new Error("设置了 CLAUDE_CONFIG_DIR;请用 --config 明确 MCP 文件,避免改错作用域。");
54
+ if (agent === "kimi-code" && user && !env.KIMI_CODE_HOME && !existsSync(join(home, ".kimi-code")) && existsSync(join(home, ".kimi/mcp.json")) && !options.config) throw new Error("检测到旧版 Kimi 配置,请核对版本后用 --config 明确文件;本适配器面向 .kimi-code 布局。");
55
+ return {
56
+ agent,
57
+ scope,
58
+ path: resolve(options.config ?? preferred(paths[agent])),
59
+ format: agent === "codex" ? "toml" : "jsonc",
60
+ key: agent === "codex" ? ["mcp_servers"] : agent === "opencode" ? ["mcp"] : agent === "zcode" ? ["mcp", "servers"] : ["mcpServers"]
61
+ };
62
+ }
63
+ function hasLiteralSecrets(config) {
64
+ const literal = (value) => !/^Bearer \$\{(?:user_config\.)?[A-Za-z_][A-Za-z0-9_]*\}$/i.test(value) && Boolean(value.replace(variablePattern, (_whole, _name, fallback) => fallback ?? "").trim());
65
+ const sensitive = /token|secret|password|api.?key|authorization|credential|private.?key|x-pm-(ak|sk)/i;
66
+ if (Object.entries(config.headers ?? {}).some(([key, value]) => !/^(accept|content-type|user-agent|x-pm-env|x-pm-product-line)$/i.test(key) && literal(value))) return true;
67
+ if (Object.entries(config.env ?? {}).some(([key, value]) => sensitive.test(key) && literal(value))) return true;
68
+ if ((config.args ?? []).some((value, index, args) => /^--?(token|password|secret|api-key)(=|$)/i.test(value) && literal(value.includes("=") ? value.split("=").slice(1).join("=") : args[index + 1] ?? ""))) return true;
69
+ if (config.url) {
70
+ if ([...new URL(config.url.replace(variablePattern, "__BFI_VARIABLE__")).searchParams].some(([key, value]) => sensitive.test(key) && value.replaceAll("__BFI_VARIABLE__", ""))) return true;
71
+ }
72
+ return false;
73
+ }
74
+ function encodeServer(config, target, bindings = {}, materialize = false, environment = process.env) {
75
+ if (config.cwd && ![
76
+ "codex",
77
+ "qwen-code",
78
+ "gemini-cli",
79
+ "kimi-code"
80
+ ].includes(target.agent)) throw new Error("此宿主的 MCP cwd 字段未支持或未确证;请提供不依赖 cwd 的启动配置。");
81
+ if (Object.values(bindings).some((name) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) throw new Error("环境变量绑定名称无效。");
82
+ if (materialize && target.scope !== "user") throw new Error("凭据物化仅允许用户级配置,项目级配置只能保存引用。");
83
+ if (hasLiteralSecrets(config) && !materialize) throw new Error("源配置可能含明文凭据;请改为环境变量占位符,或显式选择用户级 --materialize-inputs。");
84
+ let value = materialize ? resolveVariables(config, bindings, environment) : config;
85
+ if (variables(value).length && ["zcode", "kimi-code"].includes(target.agent)) throw new Error("该宿主的通用变量插值未确证。请使用用户级 --materialize-inputs,或原生秘密配置;不会保存无效占位符。");
86
+ if (!materialize && target.agent !== "codex") value = mapStrings(value, (text) => text.replace(variablePattern, (_whole, key, fallback) => {
87
+ const name = bindings[key] ?? key;
88
+ if (fallback !== void 0 && !["claude-code", "codebuddy"].includes(target.agent)) throw new Error("此宿主不支持带默认值的变量引用。");
89
+ if (target.agent === "cursor") return `\${env:${name}}`;
90
+ if (target.agent === "opencode") return `{env:${name}}`;
91
+ return `\${${name}${fallback === void 0 ? "" : `:-${fallback}`}}`;
92
+ }));
93
+ const { type, ...rest } = value;
94
+ if (target.agent === "codex") {
95
+ if (type === "sse") throw new Error("Codex 不支持旧 SSE 配置;请提供 Streamable HTTP 服务。");
96
+ const result = {};
97
+ if (type === "stdio") {
98
+ result.command = value.command;
99
+ if (value.args) result.args = value.args;
100
+ if (value.cwd) result.cwd = value.cwd;
101
+ const env = {};
102
+ const inherited = [];
103
+ for (const [name, text] of Object.entries(value.env ?? {})) {
104
+ const match = /^\$\{(?:user_config\.)?([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(text);
105
+ if (match && (bindings[match[1]] ?? match[1]) === name) inherited.push(name);
106
+ else if (text.includes("${")) throw new Error("Codex env_vars 不能重命名或拼接变量;请使用同名环境变量或用户级物化。");
107
+ else env[name] = text;
108
+ }
109
+ if (Object.keys(env).length) result.env = env;
110
+ if (inherited.length) result.env_vars = inherited;
111
+ if ([
112
+ value.command,
113
+ value.cwd,
114
+ ...value.args ?? []
115
+ ].some((item) => item?.includes("${"))) throw new Error("Codex command/args/cwd 不支持变量插值。");
116
+ } else {
117
+ result.url = value.url;
118
+ if (value.url?.includes("${")) throw new Error("Codex URL 不支持变量插值。");
119
+ const literal = {};
120
+ const inherited = {};
121
+ for (const [header, text] of Object.entries(value.headers ?? {})) {
122
+ const match = /^\$\{(?:user_config\.)?([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(text);
123
+ const bearer = /^Bearer \$\{(?:user_config\.)?([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(text);
124
+ if (match) inherited[header] = bindings[match[1]] ?? match[1];
125
+ else if (header.toLowerCase() === "authorization" && bearer) result.bearer_token_env_var = bindings[bearer[1]] ?? bearer[1];
126
+ else if (text.includes("${")) throw new Error("Codex header 只支持整值环境引用或 Bearer 引用,不支持拼接。");
127
+ else literal[header] = text;
128
+ }
129
+ if (Object.keys(literal).length) result.http_headers = literal;
130
+ if (Object.keys(inherited).length) result.env_http_headers = inherited;
131
+ }
132
+ return result;
133
+ }
134
+ if (target.agent === "opencode") {
135
+ if (value.cwd) throw new Error("OpenCode MCP 无独立 cwd 字段;请提供绝对启动路径。");
136
+ return type === "stdio" ? {
137
+ type: "local",
138
+ command: [value.command, ...value.args ?? []],
139
+ ...value.env ? { environment: value.env } : {}
140
+ } : {
141
+ type: "remote",
142
+ url: value.url,
143
+ ...value.headers ? { headers: value.headers } : {}
144
+ };
145
+ }
146
+ if (["qwen-code", "gemini-cli"].includes(target.agent)) {
147
+ const { url, ...fields } = rest;
148
+ return {
149
+ ...fields,
150
+ ...url ? { [type === "http" ? "httpUrl" : "url"]: url } : {}
151
+ };
152
+ }
153
+ if (target.agent === "cursor") return rest;
154
+ if (target.agent === "kimi-code") return {
155
+ ...rest,
156
+ ...type === "sse" ? { transport: "sse" } : {}
157
+ };
158
+ return {
159
+ type,
160
+ ...rest
161
+ };
162
+ }
163
+ const managedKeys = /* @__PURE__ */ new Set([
164
+ "type",
165
+ "transport",
166
+ "command",
167
+ "args",
168
+ "env",
169
+ "environment",
170
+ "cwd",
171
+ "url",
172
+ "httpUrl",
173
+ "headers",
174
+ "http_headers",
175
+ "env_http_headers",
176
+ "env_vars",
177
+ "bearer_token_env_var"
178
+ ]);
179
+ function mergeServer(existing, desired) {
180
+ if (existing === void 0) return desired;
181
+ const previous = object(existing, "已有 MCP");
182
+ const extras = Object.fromEntries(Object.entries(previous).filter(([key]) => !managedKeys.has(key)));
183
+ if ([
184
+ "oauth",
185
+ "auth",
186
+ "headersHelper",
187
+ "http_headers_helper",
188
+ "bearerTokenEnvVar",
189
+ "bearer_token"
190
+ ].some((key) => extras[key] !== void 0) && [
191
+ "url",
192
+ "httpUrl",
193
+ "command",
194
+ "type",
195
+ "transport"
196
+ ].some((key) => previous[key] !== desired[key])) throw new Error("已有服务器包含宿主专有认证设置,不能安全更换启动目标;请在客户端中处理。");
197
+ return {
198
+ ...extras,
199
+ ...desired
200
+ };
201
+ }
202
+ function decodeServer(raw, target) {
203
+ const source = object(raw, "MCP");
204
+ if (source.cwd && ![
205
+ "codex",
206
+ "qwen-code",
207
+ "gemini-cli",
208
+ "kimi-code"
209
+ ].includes(target.agent)) throw new Error("此宿主 cwd 语义未确证,请使用客户端原生测试。");
210
+ if (source.enabled === false || source.disabled === true) throw new Error("此 MCP 已在客户端停用;测试不会绕过停用设置。");
211
+ if ([
212
+ "oauth",
213
+ "auth",
214
+ "headersHelper",
215
+ "http_headers_helper",
216
+ "bearer_token",
217
+ "bearerTokenEnvVar"
218
+ ].some((key) => source[key] !== void 0)) throw new Error("此服务器使用宿主专有认证;请用客户端原生连接测试。");
219
+ let result = Object.fromEntries(Object.entries(source).filter(([key]) => managedKeys.has(key)));
220
+ if (target.agent === "codex") {
221
+ const { env_vars, env_http_headers, http_headers, bearer_token_env_var, ...rest } = result;
222
+ result = rest;
223
+ if (source.command) {
224
+ const env = { ...object(source.env ?? {}) };
225
+ if (env_vars !== void 0 && (!Array.isArray(env_vars) || !env_vars.every((item) => typeof item === "string"))) throw new Error("不支持的 Codex env_vars 结构。");
226
+ for (const name of env_vars ?? []) env[name] = `\${${name}}`;
227
+ if (Object.keys(env).length) result.env = env;
228
+ } else {
229
+ const headers = { ...object(http_headers ?? {}) };
230
+ for (const [name, variable] of Object.entries(object(env_http_headers ?? {}))) {
231
+ if (typeof variable !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable)) throw new Error("无效环境变量引用。");
232
+ headers[name] = `\${${variable}}`;
233
+ }
234
+ if (typeof bearer_token_env_var === "string") headers.Authorization = `Bearer \${${bearer_token_env_var}}`;
235
+ if (Object.keys(headers).length) result.headers = headers;
236
+ }
237
+ } else if (target.agent === "opencode") {
238
+ if (source.type === "local") {
239
+ if (!Array.isArray(source.command) || !source.command.length) throw new Error("OpenCode command 必须为非空数组。");
240
+ result = {
241
+ type: "stdio",
242
+ command: source.command[0],
243
+ args: source.command.slice(1),
244
+ ...source.environment ? { env: source.environment } : {}
245
+ };
246
+ } else result = {
247
+ type: "http",
248
+ url: source.url,
249
+ ...source.headers ? { headers: source.headers } : {}
250
+ };
251
+ result = mapStrings(result, (text) => text.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, "${$1}"));
252
+ } else if (["qwen-code", "gemini-cli"].includes(target.agent)) {
253
+ const { httpUrl, ...rest } = result;
254
+ result = {
255
+ ...rest,
256
+ ...httpUrl ? {
257
+ type: "http",
258
+ url: httpUrl
259
+ } : source.url ? { type: "sse" } : {}
260
+ };
261
+ } else if (target.agent === "cursor") result = mapStrings(result, (text) => text.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, "${$1}"));
262
+ return normalizeConfig(result);
263
+ }
264
+ function checkNode(node) {
265
+ if (node.type === "object") {
266
+ const keys = (node.children ?? []).map((child) => String(child.children?.[0]?.value));
267
+ if (keys.length !== new Set(keys).size || keys.some((key) => [
268
+ "__proto__",
269
+ "prototype",
270
+ "constructor"
271
+ ].includes(key))) throw new Error("配置包含重复键或不安全键,拒绝修改。");
272
+ }
273
+ node.children?.forEach(checkNode);
274
+ }
275
+ function readDocument(text, format) {
276
+ if (!text.trim()) return {};
277
+ try {
278
+ if (format === "toml") return object(parse$1(text));
279
+ const errors = [];
280
+ const tree = parseTree(text, errors, {
281
+ allowTrailingComma: true,
282
+ disallowComments: false
283
+ });
284
+ if (!tree || errors.length) throw new Error();
285
+ checkNode(tree);
286
+ return object(JSON.parse(JSON.stringify(getNodeValue(tree))));
287
+ } catch {
288
+ throw new Error("现有配置解析失败,未修改任何内容。请先修复配置格式或重复键。");
289
+ }
290
+ }
291
+ function serversIn(document, path) {
292
+ let current = document;
293
+ for (const key of path) {
294
+ if (current[key] === void 0) return {};
295
+ current = object(current[key], "MCP 配置节点");
296
+ }
297
+ return current;
298
+ }
299
+ function tablePath(line) {
300
+ if (!/^\s*\[[^[]/.test(line)) return;
301
+ try {
302
+ let value = parse$1(`${line}\n__bfi_table_marker__ = true\n`);
303
+ const path = [];
304
+ while (value && typeof value === "object") {
305
+ const entries = Object.entries(value);
306
+ if (entries.length !== 1) return;
307
+ const [key, next] = entries[0];
308
+ if (key === "__bfi_table_marker__") return path;
309
+ path.push(key);
310
+ value = next;
311
+ }
312
+ } catch {
313
+ return;
314
+ }
315
+ }
316
+ function editToml(text, replacements) {
317
+ const lines = text.split(/(?<=\n)/);
318
+ const starts = [];
319
+ let offset = 0;
320
+ for (const line of lines) {
321
+ if (/^\s*\[/.test(line)) starts.push({
322
+ offset,
323
+ path: tablePath(line.trim())
324
+ });
325
+ offset += line.length;
326
+ }
327
+ const existing = serversIn(readDocument(text, "toml"), ["mcp_servers"]);
328
+ const ranges = [];
329
+ for (const name of Object.keys(replacements)) {
330
+ const matches = starts.flatMap((start, index) => start.path?.[0] === "mcp_servers" && start.path[1] === name ? [[start.offset, starts[index + 1]?.offset ?? text.length]] : []);
331
+ if (existing[name] !== void 0 && !matches.length) throw new Error("目标 MCP 使用内联或复杂 TOML 定义,无法安全增量更新;请先改为独立 [mcp_servers.\"name\"] 表。");
332
+ ranges.push(...matches);
333
+ }
334
+ let result = text;
335
+ for (const [start, end] of ranges.sort((left, right) => right[0] - left[0])) result = result.slice(0, start) + result.slice(end);
336
+ for (const [name, config] of Object.entries(replacements)) if (config !== void 0) result += `${result.endsWith("\n") || !result ? "" : "\n"}\n${stringify({ mcp_servers: { [name]: config } }).replace(/^\[mcp_servers\]\r?\n/m, "")}`;
337
+ if (!readDocument(result, "toml").mcp_servers) result += "\n[mcp_servers]\n";
338
+ return result;
339
+ }
340
+ function editDocument(text, format, path, replacements) {
341
+ serversIn(readDocument(text, format), path);
342
+ const expected = readDocument(text, format);
343
+ let parent = expected;
344
+ for (const key of path) parent = object(parent[key] ?? (parent[key] = {}));
345
+ for (const [name, value] of Object.entries(replacements)) if (value === void 0) delete parent[name];
346
+ else parent[name] = value;
347
+ let result = text.trim() ? text : format === "jsonc" ? "{}\n" : "";
348
+ if (format === "toml") result = editToml(result, replacements);
349
+ else for (const [name, value] of Object.entries(replacements)) result = applyEdits(result, modify(result, [...path, name], value, { formattingOptions: {
350
+ insertSpaces: true,
351
+ tabSize: 2,
352
+ eol: text.includes("\r\n") ? "\r\n" : "\n"
353
+ } }));
354
+ if (!isDeepStrictEqual(readDocument(result, format), expected)) throw new Error("配置保留校验失败,未写入;请使用客户端原生配置处理复杂结构。");
355
+ return result;
356
+ }
357
+ async function loadTarget(target) {
358
+ const fileIdentity = await identity(target.path);
359
+ const text = await readConfig(target.path);
360
+ if (await identity(target.path) !== fileIdentity) throw new Error("读取时配置发生变化,请重试。");
361
+ return {
362
+ text,
363
+ fileIdentity,
364
+ servers: serversIn(readDocument(text ?? "", target.format), target.key)
365
+ };
366
+ }
367
+ async function planInstall(target, entries, options = {}) {
368
+ if (!entries.length || entries.length > 100) throw new Error("每次需要选择 1–100 个 MCP。");
369
+ const names = entries.map((entry) => validName(entry.registrationName));
370
+ if (new Set(names).size !== names.length) throw new Error("安装列表包含重复名称。");
371
+ const knownVariables = new Set(entries.flatMap((entry) => variables(entry.config)));
372
+ if (Object.keys(options.bindings ?? {}).some((key) => !knownVariables.has(key))) throw new Error("环境绑定包含未使用的变量名,请检查 --input-env。");
373
+ for (const [server, bindings] of Object.entries(options.bindingsByServer ?? {})) {
374
+ const entry = entries.find((candidate) => candidate.registrationName === server);
375
+ if (!entry || Object.keys(bindings).some((key) => !variables(entry.config).includes(key))) throw new Error("服务器环境绑定包含未使用的名称。");
376
+ }
377
+ const { text, fileIdentity, servers } = await loadTarget(target);
378
+ const replacements = {};
379
+ const changes = [];
380
+ for (const entry of entries) {
381
+ if (entry.runtime?.source === "upload") throw new Error(`MCP ${entry.registrationName} 需要上传运行包。请先下载检查并准备本地启动路径,再用 --file 安装本地配置。`);
382
+ const name = entry.registrationName;
383
+ const bindings = {
384
+ ...options.bindings,
385
+ ...options.bindingsByServer?.[name]
386
+ };
387
+ const desired = encodeServer(entry.config, target, bindings, options.materialize, options.environment);
388
+ const merged = mergeServer(servers[name], desired);
389
+ const action = servers[name] === void 0 ? "add" : isDeepStrictEqual(servers[name], merged) ? "unchanged" : "replace";
390
+ if (action === "replace" && !options.replace) throw new Error(`同名 MCP ${name} 配置不同;未修改任何服务器。确认要替换其连接配置时使用 --replace。`);
391
+ if (action !== "unchanged") replacements[name] = merged;
392
+ changes.push({
393
+ name,
394
+ action,
395
+ transport: entry.config.type,
396
+ variables: variables(entry.config).map((key) => bindings[key] ?? key)
397
+ });
398
+ }
399
+ return {
400
+ target,
401
+ previous: text,
402
+ next: Object.keys(replacements).length ? editDocument(text ?? "", target.format, target.key, replacements) : text,
403
+ fileIdentity,
404
+ changes
405
+ };
406
+ }
407
+ async function planRemove(target, names) {
408
+ if (!names.length || names.length > 100) throw new Error("请明确指定 1–100 个要移除的 MCP。");
409
+ const { text, fileIdentity, servers } = await loadTarget(target);
410
+ const replacements = {};
411
+ const changes = [...new Set(names)].map((name) => {
412
+ validName(name);
413
+ const action = Object.hasOwn(servers, name) ? "remove" : "absent";
414
+ if (action === "remove") replacements[name] = void 0;
415
+ return {
416
+ name,
417
+ action
418
+ };
419
+ });
420
+ return {
421
+ target,
422
+ previous: text,
423
+ next: Object.keys(replacements).length ? editDocument(text ?? "", target.format, target.key, replacements) : text,
424
+ fileIdentity,
425
+ changes
426
+ };
427
+ }
428
+ async function applyPlan(plan) {
429
+ if (plan.previous === plan.next || plan.next === void 0) return { changed: false };
430
+ if (await identity(plan.target.path) !== plan.fileIdentity) throw new Error("配置在预览后发生变化,请重新运行;未写入。");
431
+ return {
432
+ changed: true,
433
+ backup: await writeConfig(plan.target.path, plan.previous, plan.next, plan.fileIdentity)
434
+ };
435
+ }
436
+ export { agentIds, applyPlan, decodeServer, loadTarget, planInstall, planRemove, readDocument, serversIn, targetFor };