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,698 @@
|
|
|
1
|
+
import { isCancel } from "./libs/@clack/core.mjs";
|
|
2
|
+
import { confirm as confirm$1, multiselect, select } from "./libs/@clack/prompts.mjs";
|
|
3
|
+
import { inspectPluginArchive } from "./intent.mjs";
|
|
4
|
+
import { loadGlobalConfig, loadProjectConfig, resolveConfig } from "./config.mjs";
|
|
5
|
+
import { agents, detectInstalledAgents, setAgents } from "./agents.mjs";
|
|
6
|
+
import { SkillHubAPI, getAgentBaseDir } from "../cli.mjs";
|
|
7
|
+
import { builtinCommands, normalizeConfig, variables } from "./model.mjs";
|
|
8
|
+
import { planInstall, readDocument, serversIn, targetFor } from "./manager.mjs";
|
|
9
|
+
import { checkPath, readConfig, writeConfig } from "./files.mjs";
|
|
10
|
+
import { canonical, hash, stableManifest, verifyPlan } from "./digest.mjs";
|
|
11
|
+
import { InstallationClient, credentialForRegistry, registryUrl } from "./client.mjs";
|
|
12
|
+
import { resolveAgents } from "./agents2.mjs";
|
|
13
|
+
import { assertWritable, atomicPrivate, checkpoint, fingerprint, operationRoot, ownershipDigest, pathKey, readReceipt, receipts, safeDirectory, treeFingerprint, withInstallationLock } from "./receipt.mjs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { constants } from "node:fs";
|
|
16
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
|
+
import { access, cp, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
async function inspectBundle(bytes, online) {
|
|
20
|
+
const tree = await inspectPluginArchive(bytes, "installation.zip", {
|
|
21
|
+
compressedBytes: 510 * 1024 ** 2,
|
|
22
|
+
expandedBytes: 512 * 1024 ** 2,
|
|
23
|
+
fileBytes: 50 * 1024 ** 2,
|
|
24
|
+
entries: 40010
|
|
25
|
+
});
|
|
26
|
+
const manifest = tree.files.find((file) => file.path === "installation-manifest.json");
|
|
27
|
+
if (!manifest || manifest.content.length > 12 * 1024 ** 2) throw new Error("归档缺少完整安装清单,不支持旧版技能 ZIP。");
|
|
28
|
+
const stable = JSON.parse(manifest.content.toString("utf8"));
|
|
29
|
+
if ("id" in stable || "expiresAt" in stable) throw new Error("归档清单不应含在线计划状态。");
|
|
30
|
+
const plan = verifyPlan({
|
|
31
|
+
...stable,
|
|
32
|
+
id: online?.id ?? "offline",
|
|
33
|
+
expiresAt: online?.expiresAt ?? "9999-01-01T00:00:00.000Z"
|
|
34
|
+
});
|
|
35
|
+
if (online && canonical(stableManifest(online)) !== canonical(stableManifest(plan))) throw new Error("归档与固定内容计划不一致。");
|
|
36
|
+
const metadata = /* @__PURE__ */ new Set([
|
|
37
|
+
"installation-manifest.json",
|
|
38
|
+
"README.md",
|
|
39
|
+
"mcp/servers.json"
|
|
40
|
+
]);
|
|
41
|
+
const files = tree.files.filter((file) => !metadata.has(file.path));
|
|
42
|
+
if (files.length !== plan.files.length) throw new Error("归档文件清单不完整或包含额外文件。");
|
|
43
|
+
const actual = new Map(files.map((file) => [file.path, file]));
|
|
44
|
+
for (const expected of plan.files) {
|
|
45
|
+
const file = actual.get(expected.path);
|
|
46
|
+
if (!file || file.content.length !== expected.size || hash(file.content) !== expected.sha256 || file.executable !== expected.executable) throw new Error("归档文件内容或权限校验失败。");
|
|
47
|
+
}
|
|
48
|
+
const servers = tree.files.find((file) => file.path === "mcp/servers.json");
|
|
49
|
+
if (!servers || canonical(JSON.parse(servers.content.toString("utf8"))) !== canonical({ mcpServers: Object.fromEntries(plan.mcps.map((mcp) => [mcp.registrationName, mcp.config])) })) throw new Error("MCP 文件与清单不一致。");
|
|
50
|
+
return {
|
|
51
|
+
plan,
|
|
52
|
+
files,
|
|
53
|
+
offline: !online
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function parseInstallArguments(args) {
|
|
57
|
+
const result = { inputEnv: [] };
|
|
58
|
+
const seen = /* @__PURE__ */ new Set();
|
|
59
|
+
const values = {
|
|
60
|
+
"--registry": "registry",
|
|
61
|
+
"--content-plan": "contentPlan",
|
|
62
|
+
"--from-archive": "fromArchive",
|
|
63
|
+
"--operation": "operation"
|
|
64
|
+
};
|
|
65
|
+
for (let index = 0; index < args.length; index++) {
|
|
66
|
+
const argument = args[index];
|
|
67
|
+
if (!argument.startsWith("-")) {
|
|
68
|
+
if (result.ref || !/^@[\w.-]+\/[\w.-]+$/.test(argument)) throw new Error("请指定唯一的 @空间/名称。");
|
|
69
|
+
result.ref = argument;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const split = argument.indexOf("=");
|
|
73
|
+
const raw = split < 0 ? argument : argument.slice(0, split);
|
|
74
|
+
const flag = {
|
|
75
|
+
"-g": "--global",
|
|
76
|
+
"-y": "--yes",
|
|
77
|
+
"-a": "--agent",
|
|
78
|
+
"-h": "--help"
|
|
79
|
+
}[raw] ?? raw;
|
|
80
|
+
if (seen.has(flag) && !["--agent", "--input-env"].includes(flag)) throw new Error(`重复参数:${flag}`);
|
|
81
|
+
seen.add(flag);
|
|
82
|
+
if ([
|
|
83
|
+
"--agent",
|
|
84
|
+
"--input-env",
|
|
85
|
+
"--scope",
|
|
86
|
+
"--as",
|
|
87
|
+
...Object.keys(values)
|
|
88
|
+
].includes(flag)) {
|
|
89
|
+
const value = split < 0 ? args[++index] : argument.slice(split + 1);
|
|
90
|
+
if (!value || value.startsWith("-") || /[\x00-\x1f\x7f]/.test(value)) throw new Error(`缺少有效参数:${flag}`);
|
|
91
|
+
if (flag === "--agent") (result.agents ??= []).push(...value.split(","));
|
|
92
|
+
else if (flag === "--input-env") result.inputEnv.push(value);
|
|
93
|
+
else if (flag === "--scope") {
|
|
94
|
+
if (!["user", "project"].includes(value)) throw new Error("范围仅支持 user 或 project。");
|
|
95
|
+
result.scope = value;
|
|
96
|
+
} else if (flag === "--as") {
|
|
97
|
+
if (value !== "skills") throw new Error("插件必须走原生插件安装入口。");
|
|
98
|
+
} else Object.assign(result, { [values[flag]]: value });
|
|
99
|
+
} else {
|
|
100
|
+
if (split >= 0) throw new Error(`参数不接受值:${flag}`);
|
|
101
|
+
if (flag === "--global") result.scope = "user";
|
|
102
|
+
else if (flag === "--yes") result.yes = true;
|
|
103
|
+
else if (flag === "--replace") result.replace = true;
|
|
104
|
+
else if (flag === "--environment-ready") result.environmentReady = true;
|
|
105
|
+
else if (flag === "--help") result.help = true;
|
|
106
|
+
else if (flag === "--dry-run") result.dryRun = true;
|
|
107
|
+
else if (flag === "--json") result.json = true;
|
|
108
|
+
else throw new Error(`未知安装参数:${flag}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (seen.has("--scope") && seen.has("--global")) throw new Error("不能同时指定 --scope 与 --global。");
|
|
112
|
+
if ([
|
|
113
|
+
result.contentPlan,
|
|
114
|
+
result.fromArchive,
|
|
115
|
+
result.operation
|
|
116
|
+
].filter(Boolean).length > 1) throw new Error("计划、离线归档、恢复操作不能混用。");
|
|
117
|
+
if (result.fromArchive && result.ref) throw new Error("离线归档不能同时指定在线资源。");
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
async function resolveDestination(options, prompts, cwd = process.cwd()) {
|
|
121
|
+
if (!options.agents?.length && !prompts) throw new Error("请指定 --agent;没有交互终端时不能猜测 Agent。");
|
|
122
|
+
if (!options.scope && !prompts) throw new Error("请指定 --scope user|project;--yes 不会代选安装范围。");
|
|
123
|
+
const agents = [...new Set(options.agents ?? await prompts.agents())];
|
|
124
|
+
const scope = options.scope ?? await prompts.scope();
|
|
125
|
+
if (!agents.length || !["user", "project"].includes(scope)) throw new Error("已取消安装。");
|
|
126
|
+
return {
|
|
127
|
+
agents,
|
|
128
|
+
scope,
|
|
129
|
+
...scope === "project" ? { projectPath: resolve(cwd) } : {}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function parseBindings(values, plan) {
|
|
133
|
+
const result = {};
|
|
134
|
+
for (const value of values) {
|
|
135
|
+
const split = value.indexOf("=");
|
|
136
|
+
const source = value.slice(0, split);
|
|
137
|
+
const variable = value.slice(split + 1);
|
|
138
|
+
const matches = plan.mcps.flatMap((mcp) => variables(normalizeConfig(mcp.config)).filter((key) => `${mcp.registrationName}.${key}` === source).map((key) => ({
|
|
139
|
+
server: mcp.registrationName,
|
|
140
|
+
key
|
|
141
|
+
})));
|
|
142
|
+
if (split < 0 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable) || matches.length !== 1) throw new Error("变量绑定须为 MCP名称.参数名=环境变量名,且必须来自此计划。");
|
|
143
|
+
const match = matches[0];
|
|
144
|
+
const bindings = result[match.server] ??= {};
|
|
145
|
+
if (bindings[match.key]) throw new Error("重复变量绑定。");
|
|
146
|
+
bindings[match.key] = variable;
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
async function executable(command, env) {
|
|
151
|
+
const paths = isAbsolute(command) ? [command] : command.includes("/") || command.includes("\\") ? [] : (env.PATH ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory, command));
|
|
152
|
+
for (const path of paths) for (const suffix of process.platform === "win32" ? [
|
|
153
|
+
"",
|
|
154
|
+
".exe",
|
|
155
|
+
".cmd",
|
|
156
|
+
".bat"
|
|
157
|
+
] : [""]) try {
|
|
158
|
+
await access(path + suffix, constants.X_OK);
|
|
159
|
+
return true;
|
|
160
|
+
} catch {}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
async function checkMcpInputs(plan, bindingsByServer, env, environmentReady = false) {
|
|
164
|
+
for (const mcp of plan.mcps) {
|
|
165
|
+
const config = normalizeConfig(mcp.config);
|
|
166
|
+
if (mcp.runtime?.source === "upload") throw new Error(`MCP ${mcp.registrationName} 的上传运行包尚不能在完整组合中自动安装。`);
|
|
167
|
+
const keys = variables(config);
|
|
168
|
+
if (Object.keys(bindingsByServer[mcp.registrationName] ?? {}).some((key) => !keys.includes(key))) throw new Error("变量绑定不属于此 MCP。");
|
|
169
|
+
for (const key of /* @__PURE__ */ new Set([...keys, ...mcp.inputs.filter((input) => input.required).map((input) => input.key)])) if (!env[bindingsByServer[mcp.registrationName]?.[key] ?? key]) throw new Error(`缺少变量 ${mcp.registrationName}.${key},请安全设置本机环境并用 --input-env 绑定;不要在命令行填写密钥值。`);
|
|
170
|
+
if (keys.length && !environmentReady) throw new Error("请确认目标 Agent(包括桌面进程)可读取所绑定的环境变量,再加 --environment-ready;仅 CLI 进程可读不足以证明宿主可用。");
|
|
171
|
+
if (config.type === "stdio" && !await executable(config.command, env)) throw new Error(`找不到 MCP 启动命令 ${mcp.registrationName},请先准备其本机依赖。`);
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(bindingsByServer).some((name) => !plan.mcps.some((mcp) => mcp.registrationName === name))) throw new Error("变量绑定包含计划外 MCP。");
|
|
174
|
+
}
|
|
175
|
+
function skillResource(registry, id) {
|
|
176
|
+
return canonical([registry.replace(/\/$/, ""), id]);
|
|
177
|
+
}
|
|
178
|
+
function skillDirectoryName(registry, id) {
|
|
179
|
+
return `bfi-skill-${hash(skillResource(registry, id))}`;
|
|
180
|
+
}
|
|
181
|
+
function sameInstallation(record, plan, destination) {
|
|
182
|
+
return record.plan.registry === plan.registry && record.plan.resourceId === plan.resourceId && record.plan.kind === plan.kind && canonical({
|
|
183
|
+
...record.destination,
|
|
184
|
+
agents: [...record.destination.agents].sort()
|
|
185
|
+
}) === canonical({
|
|
186
|
+
...destination,
|
|
187
|
+
agents: [...destination.agents].sort()
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async function prepareInstall(bundle, destination, options = {}) {
|
|
191
|
+
const home = options.home ?? homedir();
|
|
192
|
+
const env = options.env ?? process.env;
|
|
193
|
+
const plan = verifyPlan(bundle.plan);
|
|
194
|
+
const supported = [
|
|
195
|
+
"skill-install-v1",
|
|
196
|
+
"content-plan-v1",
|
|
197
|
+
"collection-bundle-v1",
|
|
198
|
+
"collection-mcp-v1",
|
|
199
|
+
"scope-selection-v1"
|
|
200
|
+
];
|
|
201
|
+
if (plan.requiredCapabilities.some((value) => !supported.includes(value))) throw new Error("内容要求当前 CLI 未支持的能力,请升级。");
|
|
202
|
+
if (!destination.agents.length || !["user", "project"].includes(destination.scope) || destination.scope === "project" && !destination.projectPath) throw new Error("安装目标不完整。");
|
|
203
|
+
if (options.previous && !sameInstallation(options.previous, plan, destination)) throw new Error("更新资源稳定身份或目标与原回执不匹配。");
|
|
204
|
+
const records = await receipts(home);
|
|
205
|
+
if (records.some((record) => record.state === "INCOMPLETE")) throw new Error("存在未完成安装,请先用 --operation <ID> 恢复,不混入新计划。");
|
|
206
|
+
await checkMcpInputs(plan, options.bindingsByServer ?? {}, env, options.environmentReady);
|
|
207
|
+
const receipt = {
|
|
208
|
+
id: randomUUID(),
|
|
209
|
+
plan,
|
|
210
|
+
destination,
|
|
211
|
+
offline: bundle.offline,
|
|
212
|
+
state: "INCOMPLETE",
|
|
213
|
+
actions: [],
|
|
214
|
+
payloads: /* @__PURE__ */ new Map(),
|
|
215
|
+
retained: [],
|
|
216
|
+
bindingsByServer: options.bindingsByServer,
|
|
217
|
+
ownershipDigest: ownershipDigest(records),
|
|
218
|
+
...options.previous ? { replaces: options.previous.id } : {}
|
|
219
|
+
};
|
|
220
|
+
const add = (action, payload) => {
|
|
221
|
+
const existing = receipt.actions.find((item) => item.path.toLowerCase().normalize("NFC") === action.path.toLowerCase().normalize("NFC"));
|
|
222
|
+
if (existing) {
|
|
223
|
+
if (existing.after !== action.after || existing.before !== action.before || existing.kind !== action.kind || existing.resource !== action.resource) throw new Error("多个目标指向同一路径但内容不兼容。");
|
|
224
|
+
existing.agents.push(...action.agents);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
action.source = String(receipt.actions.length);
|
|
228
|
+
receipt.actions.push(action);
|
|
229
|
+
receipt.payloads.set(action.source, payload);
|
|
230
|
+
};
|
|
231
|
+
for (const agentName of destination.agents) {
|
|
232
|
+
const agent = agentName;
|
|
233
|
+
if (!Object.hasOwn(agents, agent)) throw new Error(`未知技能 Agent:${agentName}`);
|
|
234
|
+
if (destination.scope === "user" && !agents[agent].globalSkillsDir) throw new Error(`Agent ${agentName} 不支持用户级技能。`);
|
|
235
|
+
let base = getAgentBaseDir(agent, destination.scope === "user", destination.projectPath);
|
|
236
|
+
if (home !== homedir() && destination.scope === "user") {
|
|
237
|
+
const suffix = relative(homedir(), base);
|
|
238
|
+
if (suffix.startsWith("..")) throw new Error("自定义技能路径不在指定 home 下。");
|
|
239
|
+
base = join(home, suffix);
|
|
240
|
+
}
|
|
241
|
+
for (const skill of plan.skills) {
|
|
242
|
+
if (!/^@[\w.-]+\/[\w.-]+$/.test(skill.ref)) throw new Error("技能引用无效。");
|
|
243
|
+
const name = skillDirectoryName(plan.registry, skill.id);
|
|
244
|
+
const path = resolve(base, name);
|
|
245
|
+
await assertWritable(path, true);
|
|
246
|
+
const before = await fingerprint(path, "skill");
|
|
247
|
+
const files = bundle.files.filter((file) => file.path.startsWith(`${skill.root}/`)).map((file) => ({
|
|
248
|
+
...file,
|
|
249
|
+
path: file.path.slice(skill.root.length + 1)
|
|
250
|
+
}));
|
|
251
|
+
const after = treeFingerprint(files.map((file) => ({
|
|
252
|
+
path: file.path,
|
|
253
|
+
sha256: hash(file.content),
|
|
254
|
+
executable: file.executable
|
|
255
|
+
})));
|
|
256
|
+
const resource = skillResource(plan.registry, skill.id);
|
|
257
|
+
const owners = records.flatMap((record) => record.actions.filter((item) => pathKey(item.path) === pathKey(path) && item.kind === "skill").map((item) => ({
|
|
258
|
+
record,
|
|
259
|
+
item
|
|
260
|
+
})));
|
|
261
|
+
if (owners.some((owner) => owner.item.resource !== resource)) throw new Error("技能路径被不同来源或稳定身份占用。");
|
|
262
|
+
if (owners.some((owner) => owner.item.after !== before)) throw new Error("本机技能已修改,不覆盖用户内容。");
|
|
263
|
+
if (before !== void 0 && before !== after) {
|
|
264
|
+
if (owners.some((owner) => owner.record.id !== options.previous?.id)) throw new Error("技能被其它安装共用,不能单独升级覆盖。");
|
|
265
|
+
if (!owners.length && !options.replace) throw new Error("技能路径已存在不同内容;需要明确 --replace。");
|
|
266
|
+
}
|
|
267
|
+
add({
|
|
268
|
+
kind: "skill",
|
|
269
|
+
path,
|
|
270
|
+
agents: [agent],
|
|
271
|
+
before,
|
|
272
|
+
after,
|
|
273
|
+
resource,
|
|
274
|
+
managed: before === void 0 || before !== after || owners.some((owner) => owner.item.managed)
|
|
275
|
+
}, files);
|
|
276
|
+
}
|
|
277
|
+
if (plan.mcps.length) {
|
|
278
|
+
const target = targetFor(agent === "kimi-code-cli" ? "kimi-code" : agent, destination.scope, {
|
|
279
|
+
home,
|
|
280
|
+
cwd: destination.projectPath,
|
|
281
|
+
env
|
|
282
|
+
});
|
|
283
|
+
await assertWritable(target.path);
|
|
284
|
+
const oldConfig = options.previous?.actions.find((action) => pathKey(action.path) === pathKey(target.path) && action.kind === "mcp");
|
|
285
|
+
const config = await planInstall(target, plan.mcps.map((mcp) => ({
|
|
286
|
+
...mcp,
|
|
287
|
+
config: normalizeConfig(mcp.config),
|
|
288
|
+
runtime: mcp.runtime ? { source: String(mcp.runtime.source) } : void 0
|
|
289
|
+
})), {
|
|
290
|
+
bindingsByServer: options.bindingsByServer,
|
|
291
|
+
environment: env,
|
|
292
|
+
replace: options.replace
|
|
293
|
+
});
|
|
294
|
+
const currentServers = serversIn(readDocument(config.previous ?? "", target.format), target.key);
|
|
295
|
+
const nextServers = serversIn(readDocument(config.next ?? "", target.format), target.key);
|
|
296
|
+
const serverHashes = Object.fromEntries(plan.mcps.map((mcp) => [mcp.registrationName, hash(canonical(nextServers[mcp.registrationName]))]));
|
|
297
|
+
if (oldConfig) {
|
|
298
|
+
for (const mcp of plan.mcps.filter((mcp) => options.previous.plan.mcps.some((previous) => previous.registrationName === mcp.registrationName))) if (!oldConfig.servers || hash(canonical(currentServers[mcp.registrationName] ?? null)) !== oldConfig.servers[mcp.registrationName]) throw new Error("受管 MCP 条目已被修改,停止更新;无关服务器不受影响。");
|
|
299
|
+
}
|
|
300
|
+
for (const record of records.filter((record) => record.id !== options.previous?.id)) for (const action of record.actions.filter((action) => action.kind === "mcp" && pathKey(action.path) === pathKey(target.path))) for (const [name, digest] of Object.entries(action.servers ?? {})) if (serverHashes[name] && serverHashes[name] !== digest) throw new Error("MCP 被其它安装共用,不能单独覆盖。");
|
|
301
|
+
add({
|
|
302
|
+
kind: "mcp",
|
|
303
|
+
path: target.path,
|
|
304
|
+
agents: [agent],
|
|
305
|
+
before: config.previous === void 0 ? void 0 : hash(config.previous),
|
|
306
|
+
after: config.next === void 0 ? void 0 : hash(config.next),
|
|
307
|
+
managed: config.previous !== config.next,
|
|
308
|
+
servers: serverHashes
|
|
309
|
+
}, Buffer.from(config.next ?? ""));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (options.previous) {
|
|
313
|
+
for (const old of options.previous.actions.filter((action) => action.kind === "skill" && !receipt.actions.some((next) => pathKey(next.path) === pathKey(action.path)))) if (old.managed && !records.some((record) => record.id !== options.previous.id && record.actions.some((action) => pathKey(action.path) === pathKey(old.path))) && await fingerprint(old.path, "skill") === old.after) receipt.actions.push({
|
|
314
|
+
...old,
|
|
315
|
+
before: old.after,
|
|
316
|
+
after: void 0,
|
|
317
|
+
done: false,
|
|
318
|
+
remove: true,
|
|
319
|
+
source: void 0
|
|
320
|
+
});
|
|
321
|
+
else receipt.retained.push(old.path);
|
|
322
|
+
if (options.previous.plan.mcps.some((old) => !plan.mcps.some((next) => next.registrationName === old.registrationName))) receipt.retained.push("已解除关联的 MCP 保留在宿主配置中。");
|
|
323
|
+
}
|
|
324
|
+
return receipt;
|
|
325
|
+
}
|
|
326
|
+
async function saveIntent(prepared, home) {
|
|
327
|
+
const root = operationRoot(prepared.id, home);
|
|
328
|
+
await safeDirectory(root);
|
|
329
|
+
await mkdir(root, {
|
|
330
|
+
recursive: true,
|
|
331
|
+
mode: 448
|
|
332
|
+
});
|
|
333
|
+
for (const [source, payload] of prepared.payloads) {
|
|
334
|
+
const target = join(root, "payload", source);
|
|
335
|
+
if (Buffer.isBuffer(payload)) await atomicPrivate(target, payload);
|
|
336
|
+
else for (const file of payload) {
|
|
337
|
+
const path = join(target, file.path);
|
|
338
|
+
await mkdir(dirname(path), {
|
|
339
|
+
recursive: true,
|
|
340
|
+
mode: 448
|
|
341
|
+
});
|
|
342
|
+
await writeFile(path, file.content, {
|
|
343
|
+
flag: "wx",
|
|
344
|
+
mode: file.executable ? 448 : 384
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const { payloads, ...receipt } = prepared;
|
|
349
|
+
await checkpoint(receipt, home);
|
|
350
|
+
return receipt;
|
|
351
|
+
}
|
|
352
|
+
async function execute(receipt, home) {
|
|
353
|
+
const root = operationRoot(receipt.id, home);
|
|
354
|
+
try {
|
|
355
|
+
for (const [index, action] of receipt.actions.entries()) {
|
|
356
|
+
const actual = await fingerprint(action.path, action.kind);
|
|
357
|
+
const backup = join(root, "backups", String(index));
|
|
358
|
+
const moved = action.kind === "skill" && actual === void 0 && action.before !== void 0 && await fingerprint(backup, "skill") === action.before;
|
|
359
|
+
if (actual !== action.after && actual !== action.before && !moved) throw new Error("本机内容与执行意图不一致,停止恢复。");
|
|
360
|
+
if (action.done && actual !== action.after) throw new Error("已完成内容被修改,停止恢复。");
|
|
361
|
+
}
|
|
362
|
+
for (const [index, action] of receipt.actions.entries()) {
|
|
363
|
+
let actual = await fingerprint(action.path, action.kind);
|
|
364
|
+
if (actual !== action.after) {
|
|
365
|
+
const backup = join(root, "backups", String(index));
|
|
366
|
+
const moved = action.kind === "skill" && actual === void 0 && action.before !== void 0 && await fingerprint(backup, "skill") === action.before;
|
|
367
|
+
if (actual !== action.before && !moved) throw new Error("写入前发现并发修改。");
|
|
368
|
+
if (action.kind === "mcp") {
|
|
369
|
+
const next = await readConfig(join(root, "payload", action.source));
|
|
370
|
+
if (next === void 0 || hash(next) !== action.after) throw new Error("恢复配置快照已损坏。");
|
|
371
|
+
const previous = await readConfig(action.path);
|
|
372
|
+
if ((previous === void 0 ? void 0 : hash(previous)) !== action.before) throw new Error("配置并发修改。");
|
|
373
|
+
await writeConfig(action.path, previous, next);
|
|
374
|
+
} else if (!action.remove) {
|
|
375
|
+
const source = join(root, "payload", action.source);
|
|
376
|
+
if (await fingerprint(source, "skill") !== action.after) throw new Error("恢复技能快照已损坏。");
|
|
377
|
+
const staging = `${action.path}.bfi-${receipt.id}`;
|
|
378
|
+
await safeDirectory(staging);
|
|
379
|
+
await rm(staging, {
|
|
380
|
+
recursive: true,
|
|
381
|
+
force: true
|
|
382
|
+
});
|
|
383
|
+
await mkdir(dirname(staging), {
|
|
384
|
+
recursive: true,
|
|
385
|
+
mode: 448
|
|
386
|
+
});
|
|
387
|
+
await cp(source, staging, {
|
|
388
|
+
recursive: true,
|
|
389
|
+
errorOnExist: true,
|
|
390
|
+
force: false
|
|
391
|
+
});
|
|
392
|
+
actual = await fingerprint(action.path, "skill");
|
|
393
|
+
if (actual !== action.before && !(actual === void 0 && moved)) throw new Error("技能目录并发修改。");
|
|
394
|
+
if (actual !== void 0) {
|
|
395
|
+
await mkdir(dirname(backup), {
|
|
396
|
+
recursive: true,
|
|
397
|
+
mode: 448
|
|
398
|
+
});
|
|
399
|
+
await rename(action.path, backup);
|
|
400
|
+
}
|
|
401
|
+
await rename(staging, action.path);
|
|
402
|
+
} else if (actual !== void 0) {
|
|
403
|
+
await mkdir(dirname(backup), {
|
|
404
|
+
recursive: true,
|
|
405
|
+
mode: 448
|
|
406
|
+
});
|
|
407
|
+
await rename(action.path, backup);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
action.done = true;
|
|
411
|
+
await checkpoint(receipt, home);
|
|
412
|
+
}
|
|
413
|
+
receipt.state = receipt.intent === "uninstall" ? "REMOVED" : "INSTALLED";
|
|
414
|
+
await checkpoint(receipt, home);
|
|
415
|
+
return receipt;
|
|
416
|
+
} catch {
|
|
417
|
+
receipt.state = "INCOMPLETE";
|
|
418
|
+
throw new Error(`INCOMPLETE:安装未全部完成,未回滚用户内容。用 --operation ${receipt.id} 恢复;回执和私有备份位于 ${root}。`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function applyInstall(prepared, options = {}) {
|
|
422
|
+
return withInstallationLock(async () => {
|
|
423
|
+
const current = await receipts(options.home);
|
|
424
|
+
if (current.some((record) => record.state === "INCOMPLETE")) throw new Error("存在未完成执行,请先恢复。");
|
|
425
|
+
if (prepared.ownershipDigest !== ownershipDigest(current)) throw new Error("安装归属在预览后发生变化,请重新预检确认。");
|
|
426
|
+
return execute(await saveIntent(prepared, options.home), options.home);
|
|
427
|
+
}, options.home);
|
|
428
|
+
}
|
|
429
|
+
async function resumeInstall(id, options = {}) {
|
|
430
|
+
return withInstallationLock(async () => {
|
|
431
|
+
const receipt = await readReceipt(id, options.home);
|
|
432
|
+
if (receipt.state === "INCOMPLETE" && receipt.ownershipDigest !== ownershipDigest((await receipts(options.home)).filter((record) => record.id !== id))) throw new Error("安装归属已发生变化,不能自动恢复。");
|
|
433
|
+
return execute(receipt, options.home);
|
|
434
|
+
}, options.home);
|
|
435
|
+
}
|
|
436
|
+
function matchingReceipts(records, selector) {
|
|
437
|
+
return records.filter((record) => registryUrl(record.plan.registry) === registryUrl(selector.registry) && record.plan.ref === selector.ref && record.plan.kind === selector.kind && (!selector.scope || record.destination.scope === selector.scope) && (!selector.projectPath || record.destination.scope !== "project" || record.destination.projectPath === selector.projectPath) && (!selector.agents || selector.agents.every((agent) => record.destination.agents.includes(agent)) && selector.agents.length === record.destination.agents.length));
|
|
438
|
+
}
|
|
439
|
+
function selectReceipt(records, selector) {
|
|
440
|
+
const matches = matchingReceipts(records, selector);
|
|
441
|
+
if (matches.length !== 1) throw new Error(matches.length ? "存在多套安装,请明确 Agent、范围及项目目录或交互选择。" : "没有精确安装回执;旧记录无法证明归属,请重新确认目标安装,不按同名技能猜测更新。");
|
|
442
|
+
if (matches[0].state !== "INSTALLED") throw new Error("请先恢复未完成操作。");
|
|
443
|
+
return matches[0];
|
|
444
|
+
}
|
|
445
|
+
async function prepareUninstall(previous, home) {
|
|
446
|
+
const records = await receipts(home);
|
|
447
|
+
const result = {
|
|
448
|
+
...previous,
|
|
449
|
+
id: randomUUID(),
|
|
450
|
+
intent: "uninstall",
|
|
451
|
+
state: "INCOMPLETE",
|
|
452
|
+
replaces: previous.id,
|
|
453
|
+
payloads: /* @__PURE__ */ new Map(),
|
|
454
|
+
actions: [],
|
|
455
|
+
retained: [],
|
|
456
|
+
ownershipDigest: ownershipDigest(records)
|
|
457
|
+
};
|
|
458
|
+
for (const old of previous.actions.filter((action) => action.kind === "skill" && !action.remove)) if (old.managed && !records.some((record) => record.id !== previous.id && record.actions.some((action) => pathKey(action.path) === pathKey(old.path))) && await fingerprint(old.path, "skill") === old.after) result.actions.push({
|
|
459
|
+
...old,
|
|
460
|
+
before: old.after,
|
|
461
|
+
after: void 0,
|
|
462
|
+
remove: true,
|
|
463
|
+
done: false,
|
|
464
|
+
source: void 0
|
|
465
|
+
});
|
|
466
|
+
else result.retained.push(old.path);
|
|
467
|
+
if (previous.plan.mcps.length) result.retained.push("MCP 配置默认保留;如需移除,请使用 bfi mcp remove 单独确认。");
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
const help = `完整安装:
|
|
471
|
+
bfi skills add @空间/技能 [--agent codex] [--scope user|project]
|
|
472
|
+
bfi skills package install @空间/组合 [--agent codex,claude-code] [--global]
|
|
473
|
+
bfi skills package install --from-archive ./完整清单.zip
|
|
474
|
+
bfi skills package update @空间/组合
|
|
475
|
+
bfi skills package uninstall @空间/组合
|
|
476
|
+
bfi skills package install --operation <本机操作ID>
|
|
477
|
+
选项:--content-plan <ID> --registry <URL> --input-env MCP.参数=环境变量名
|
|
478
|
+
--environment-ready 确认 Agent 可读取环境变量(桌面进程也需配置)
|
|
479
|
+
--dry-run --json --replace -y
|
|
480
|
+
未指定 Agent / 范围时交互选择,-y 不代选。全部关联 MCP 必装,不支持仅技能。
|
|
481
|
+
--replace 仅允许明确替换冲突;不会覆盖已修改或被其它安装共用的受管技能。
|
|
482
|
+
更新继承原回执;移除组合时保留 MCP、共用及本机修改内容。`;
|
|
483
|
+
function accepted(value) {
|
|
484
|
+
if (isCancel(value)) throw new Error("已取消,未继续安装。");
|
|
485
|
+
return value;
|
|
486
|
+
}
|
|
487
|
+
async function promptAdapter() {
|
|
488
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
489
|
+
const detected = await detectInstalledAgents();
|
|
490
|
+
return {
|
|
491
|
+
agents: async () => accepted(await multiselect({
|
|
492
|
+
message: "选择目标 Agent(只安装所选目标)",
|
|
493
|
+
options: Object.entries(agents).map(([value, agent]) => ({
|
|
494
|
+
value,
|
|
495
|
+
label: agent.displayName,
|
|
496
|
+
hint: detected.includes(value) ? "已检测到" : void 0
|
|
497
|
+
})),
|
|
498
|
+
required: true
|
|
499
|
+
})),
|
|
500
|
+
scope: async () => accepted(await select({
|
|
501
|
+
message: "选择安装范围",
|
|
502
|
+
options: [{
|
|
503
|
+
value: "user",
|
|
504
|
+
label: "用户级"
|
|
505
|
+
}, {
|
|
506
|
+
value: "project",
|
|
507
|
+
label: `当前项目:${process.cwd()}`
|
|
508
|
+
}]
|
|
509
|
+
}))
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async function confirm(options) {
|
|
513
|
+
if (options.yes) return;
|
|
514
|
+
if (options.json || !process.stdin.isTTY || !process.stdout.isTTY) throw new Error("非交互安装需要在明确目标和内容后指定 -y。");
|
|
515
|
+
if (!accepted(await confirm$1({
|
|
516
|
+
message: "确认安装以上完整内容?",
|
|
517
|
+
initialValue: false
|
|
518
|
+
}))) throw new Error("已取消安装。");
|
|
519
|
+
}
|
|
520
|
+
function summary(record) {
|
|
521
|
+
return {
|
|
522
|
+
operation: record.id,
|
|
523
|
+
ref: record.plan.ref,
|
|
524
|
+
registry: record.plan.registry,
|
|
525
|
+
digest: record.plan.manifestDigest,
|
|
526
|
+
destination: record.destination,
|
|
527
|
+
skills: record.plan.skills.map((skill) => ({
|
|
528
|
+
ref: skill.ref,
|
|
529
|
+
version: skill.version
|
|
530
|
+
})),
|
|
531
|
+
mcps: record.plan.mcps.map((mcp) => mcp.registrationName),
|
|
532
|
+
changes: record.actions.map((action) => ({
|
|
533
|
+
kind: action.kind,
|
|
534
|
+
path: action.path,
|
|
535
|
+
agents: action.agents,
|
|
536
|
+
action: action.remove ? "remove" : action.before === action.after ? "reuse" : action.before ? "update" : "add"
|
|
537
|
+
})),
|
|
538
|
+
retained: record.retained,
|
|
539
|
+
offline: record.offline
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
async function accountPreflight(record) {
|
|
543
|
+
if (record.intent === "uninstall") return;
|
|
544
|
+
if (!record.plan.mcps.some((mcp) => builtinCommands.has(String(mcp.config.command)))) return;
|
|
545
|
+
const login = loadGlobalConfig();
|
|
546
|
+
const registry = registryUrl(login?.registry ?? "https://bfi.yyuap.com:811");
|
|
547
|
+
await new InstallationClient(registry, credentialForRegistry(registry, login)).verifyAccount();
|
|
548
|
+
}
|
|
549
|
+
async function runInstallation(command, kind, args) {
|
|
550
|
+
const options = parseInstallArguments(args);
|
|
551
|
+
if (options.help) {
|
|
552
|
+
console.log(help);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const registry = registryUrl(resolveConfig({ registry: options.registry }).registry);
|
|
556
|
+
const globalConfig = loadGlobalConfig();
|
|
557
|
+
const projectConfig = loadProjectConfig(process.cwd());
|
|
558
|
+
const token = projectConfig?.token && projectConfig.registry && registryUrl(projectConfig.registry) === registry ? projectConfig.token : credentialForRegistry(registry, globalConfig);
|
|
559
|
+
const client = new InstallationClient(registry, token);
|
|
560
|
+
setAgents(await resolveAgents(token ? new SkillHubAPI(registry, token) : void 0));
|
|
561
|
+
let pendingOutput = {};
|
|
562
|
+
const output = (value) => {
|
|
563
|
+
if (!options.json) {
|
|
564
|
+
console.log(JSON.stringify(value, null, 2));
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
pendingOutput = {
|
|
568
|
+
...pendingOutput,
|
|
569
|
+
...value
|
|
570
|
+
};
|
|
571
|
+
if (options.dryRun || value.state) console.log(JSON.stringify(pendingOutput, null, 2));
|
|
572
|
+
};
|
|
573
|
+
if (options.operation) {
|
|
574
|
+
if (command !== "install" || options.ref || options.agents || options.scope || options.replace || options.dryRun || options.inputEnv.length) throw new Error("恢复仅使用原回执,请勿混入新目标或新变量绑定。");
|
|
575
|
+
const record = await readReceipt(options.operation);
|
|
576
|
+
if (record.plan.kind !== kind) throw new Error("恢复入口与回执资源类型不同。");
|
|
577
|
+
if (record.intent !== "uninstall") await checkMcpInputs(record.plan, record.bindingsByServer ?? {}, process.env, options.environmentReady);
|
|
578
|
+
output({
|
|
579
|
+
...summary(record),
|
|
580
|
+
notice: "使用本机冻结快照恢复;没有重新验证当前在线授权。"
|
|
581
|
+
});
|
|
582
|
+
await confirm(options);
|
|
583
|
+
await accountPreflight(record);
|
|
584
|
+
output({
|
|
585
|
+
state: (await resumeInstall(record.id)).state,
|
|
586
|
+
operation: record.id
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const existing = await receipts();
|
|
591
|
+
let previous;
|
|
592
|
+
if (command !== "install") {
|
|
593
|
+
if (!options.ref || options.contentPlan || options.fromArchive) throw new Error("更新/卸载请指定 @空间/名称,从原回执继承目标。");
|
|
594
|
+
const selector = {
|
|
595
|
+
registry,
|
|
596
|
+
kind,
|
|
597
|
+
ref: options.ref,
|
|
598
|
+
scope: options.scope,
|
|
599
|
+
agents: options.agents,
|
|
600
|
+
projectPath: resolve(process.cwd())
|
|
601
|
+
};
|
|
602
|
+
const matches = matchingReceipts(existing, selector);
|
|
603
|
+
if (matches.length > 1 && !options.json && process.stdin.isTTY && process.stdout.isTTY) {
|
|
604
|
+
const id = accepted(await select({
|
|
605
|
+
message: "选择要处理的安装记录",
|
|
606
|
+
options: matches.map((record) => ({
|
|
607
|
+
value: record.id,
|
|
608
|
+
label: `${record.destination.agents.join(", ")} / ${record.destination.scope} / ${record.destination.projectPath ?? "用户目录"}`
|
|
609
|
+
}))
|
|
610
|
+
}));
|
|
611
|
+
previous = matches.find((record) => record.id === id);
|
|
612
|
+
} else previous = selectReceipt(existing, selector);
|
|
613
|
+
}
|
|
614
|
+
if (command === "uninstall") {
|
|
615
|
+
const prepared = await prepareUninstall(previous);
|
|
616
|
+
output(summary(prepared));
|
|
617
|
+
if (options.dryRun) return;
|
|
618
|
+
await confirm(options);
|
|
619
|
+
const result = await applyInstall(prepared);
|
|
620
|
+
output({
|
|
621
|
+
state: result.state,
|
|
622
|
+
operation: result.id,
|
|
623
|
+
retained: result.retained
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (!options.ref && !options.fromArchive) throw new Error("请指定 @空间/名称或 --from-archive。");
|
|
628
|
+
const destination = previous?.destination ?? await resolveDestination(options, options.json ? void 0 : await promptAdapter());
|
|
629
|
+
let bundle;
|
|
630
|
+
if (options.fromArchive) {
|
|
631
|
+
await checkPath(resolve(options.fromArchive));
|
|
632
|
+
if ((await lstat(options.fromArchive)).size > 510 * 1024 ** 2) throw new Error("离线归档超过大小限制。");
|
|
633
|
+
bundle = await inspectBundle(await readFile(options.fromArchive));
|
|
634
|
+
if (bundle.plan.kind !== kind) throw new Error("离线归档类型与安装入口不一致。");
|
|
635
|
+
} else {
|
|
636
|
+
const plan = options.contentPlan ? await client.get(options.contentPlan, kind, options.ref) : await client.resolve(kind, options.ref);
|
|
637
|
+
if (previous && !sameInstallation(previous, plan, destination)) throw new Error("资源身份已变化,不能将重建的同名对象当作更新。");
|
|
638
|
+
const cache = join(homedir(), ".bfi/cache/installation", `${plan.manifestDigest}.zip`);
|
|
639
|
+
await checkPath(cache);
|
|
640
|
+
let archive;
|
|
641
|
+
try {
|
|
642
|
+
if ((await lstat(cache)).size > 510 * 1024 ** 2) throw new Error("缓存超过上限。");
|
|
643
|
+
archive = await readFile(cache);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (error.code !== "ENOENT") throw error;
|
|
646
|
+
archive = await client.artifact(plan);
|
|
647
|
+
}
|
|
648
|
+
bundle = await inspectBundle(archive, plan);
|
|
649
|
+
await atomicPrivate(cache, archive);
|
|
650
|
+
}
|
|
651
|
+
const prior = existing.find((record) => sameInstallation(record, bundle.plan, destination));
|
|
652
|
+
if (!previous && prior) {
|
|
653
|
+
if (prior.plan.manifestDigest !== bundle.plan.manifestDigest) throw new Error("已安装内容不同,请使用 update 确认完整变更。");
|
|
654
|
+
previous = prior;
|
|
655
|
+
}
|
|
656
|
+
const prepared = await prepareInstall(bundle, destination, {
|
|
657
|
+
previous,
|
|
658
|
+
bindingsByServer: options.inputEnv.length ? parseBindings(options.inputEnv, bundle.plan) : previous?.bindingsByServer,
|
|
659
|
+
environmentReady: options.environmentReady,
|
|
660
|
+
replace: options.replace
|
|
661
|
+
});
|
|
662
|
+
output({
|
|
663
|
+
...summary(prepared),
|
|
664
|
+
...bundle.offline ? { notice: "仅验证离线完整性,不代表当前在线权限或撤回状态。" } : {}
|
|
665
|
+
});
|
|
666
|
+
await accountPreflight(prepared);
|
|
667
|
+
if (options.dryRun) return;
|
|
668
|
+
await confirm(options);
|
|
669
|
+
const installed = await applyInstall(prepared);
|
|
670
|
+
output({
|
|
671
|
+
state: installed.state,
|
|
672
|
+
operation: installed.id,
|
|
673
|
+
notice: "文件和全部关联 MCP 已注册;未调用业务工具,宿主可能需要重新加载/信任。"
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
async function dispatchInstallation(raw) {
|
|
677
|
+
const args = ["skills", "skill"].includes(raw[0] ?? "") ? raw.slice(1) : raw;
|
|
678
|
+
const collection = ["package", "pkg"].includes(args[0] ?? "");
|
|
679
|
+
const tail = collection ? args.slice(2) : args.slice(1);
|
|
680
|
+
if (collection && tail.includes("plugin") && tail.includes("--as")) return false;
|
|
681
|
+
const verb = args[collection ? 1 : 0];
|
|
682
|
+
const command = [
|
|
683
|
+
"add",
|
|
684
|
+
"a",
|
|
685
|
+
"i",
|
|
686
|
+
"install"
|
|
687
|
+
].includes(verb ?? "") ? "install" : ["update", "up"].includes(verb ?? "") ? "update" : [
|
|
688
|
+
"uninstall",
|
|
689
|
+
"remove",
|
|
690
|
+
"rm"
|
|
691
|
+
].includes(verb ?? "") ? "uninstall" : void 0;
|
|
692
|
+
const qualified = tail.some((value) => /^@[\w.-]+\/[\w.-]+$/.test(value));
|
|
693
|
+
const requested = tail.some((value) => /^(--content-plan|--scope|--from-archive|--operation)(=|$)/.test(value));
|
|
694
|
+
if (!command || !collection && !qualified && !requested) return false;
|
|
695
|
+
await runInstallation(command, collection ? "COLLECTION" : "SKILL", tail);
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
export { dispatchInstallation };
|