bfi-cli 0.1.99 → 0.1.101

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,132 @@
1
+ import { normalizeConfig } from "./model.mjs";
2
+ import { createHash } from "node:crypto";
3
+ import { z } from "zod";
4
+ const identifier = z.string().min(1).max(256);
5
+ const digest = z.string().regex(/^[a-f0-9]{64}$/);
6
+ const relativePath = z.string().min(1).max(1024).refine((value) => !/[\\\x00-\x1f:%]/.test(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== ".."));
7
+ const registry = z.string().url().refine((value) => {
8
+ const url = new URL(value);
9
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
10
+ });
11
+ const contentPlanSchema = z.object({
12
+ schemaVersion: z.literal(1),
13
+ id: identifier,
14
+ registry,
15
+ kind: z.enum(["SKILL", "COLLECTION"]),
16
+ resourceId: identifier,
17
+ ref: z.string().regex(/^@[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/),
18
+ contentRevision: z.number().int().nonnegative().safe().optional(),
19
+ expiresAt: z.string().datetime(),
20
+ manifestDigest: digest,
21
+ requiredCapabilities: z.array(identifier).max(64),
22
+ skills: z.array(z.object({
23
+ id: identifier,
24
+ ref: identifier,
25
+ versionId: identifier,
26
+ version: identifier,
27
+ root: relativePath,
28
+ digest
29
+ }).strict()).min(1).max(1e3),
30
+ mcps: z.array(z.object({
31
+ id: identifier,
32
+ revision: z.number().int().nonnegative().safe(),
33
+ registrationName: identifier,
34
+ config: z.record(z.unknown()).refine((value) => {
35
+ try {
36
+ normalizeConfig(value);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }),
42
+ inputs: z.array(z.object({
43
+ key: identifier,
44
+ required: z.boolean(),
45
+ sensitive: z.boolean(),
46
+ description: z.string().max(2048).optional()
47
+ }).strict()).max(128),
48
+ runtime: z.record(z.unknown()).optional(),
49
+ digest
50
+ }).strict()).max(1e3),
51
+ files: z.array(z.object({
52
+ path: relativePath,
53
+ size: z.number().int().nonnegative().max(52428800),
54
+ sha256: digest,
55
+ executable: z.boolean()
56
+ }).strict()).min(1).max(2e4)
57
+ }).strict();
58
+ function hasLiteralSecret(value, sensitiveKeys) {
59
+ if (!value || typeof value !== "object") return false;
60
+ const isSensitive = (key) => sensitiveKeys.has(key.toLowerCase()) || /(token|secret|password|credential|authorization|api[_-]?key|(?:^|[_-])(?:ak|sk)$)/i.test(key);
61
+ const isReference = (entry) => typeof entry === "string" && /^(?:Bearer )?\$\{[a-zA-Z_][\w.]*\}$/.test(entry);
62
+ if (Array.isArray(value)) return value.some((entry, index) => {
63
+ if (typeof entry !== "string") return hasLiteralSecret(entry, sensitiveKeys);
64
+ const flag = entry.match(/^--?([^=\s]+)(?:=(.*))?$/);
65
+ if (flag && isSensitive(flag[1])) return !isReference(flag[2] ?? value[index + 1]);
66
+ const assignment = entry.match(/^([A-Za-z_][\w-]*)[:=]\s*(.*)$/);
67
+ return Boolean(assignment && isSensitive(assignment[1]) && !isReference(assignment[2]));
68
+ });
69
+ return Object.entries(value).some(([key, entry]) => {
70
+ if (isSensitive(key)) return !isReference(entry);
71
+ if (typeof entry === "string") {
72
+ if (key === "url") try {
73
+ const url = new URL(entry);
74
+ return Boolean(url.username || url.password || [...url.searchParams.keys()].some((param) => /token|secret|key|password/i.test(param)));
75
+ } catch {
76
+ return true;
77
+ }
78
+ }
79
+ return hasLiteralSecret(entry, sensitiveKeys);
80
+ });
81
+ }
82
+ function parseContentPlan(value) {
83
+ const parsed = contentPlanSchema.safeParse(value);
84
+ const invalid = () => {
85
+ throw new Error("INVALID_CONTENT_PLAN");
86
+ };
87
+ if (!parsed.success) return invalid();
88
+ const plan = parsed.data;
89
+ const roots = plan.skills.map((skill) => skill.root.normalize("NFC").toLowerCase());
90
+ const paths = plan.files.map((file) => file.path.normalize("NFC").toLowerCase());
91
+ const pathSet = new Set(paths);
92
+ const unique = (values) => new Set(values).size === values.length;
93
+ if (!unique(paths) || paths.some((path) => {
94
+ let parent = path;
95
+ while (parent.includes("/")) {
96
+ parent = parent.slice(0, parent.lastIndexOf("/"));
97
+ if (pathSet.has(parent)) return true;
98
+ }
99
+ return false;
100
+ }) || !unique(plan.skills.map((skill) => skill.id)) || !unique(plan.mcps.map((mcp) => mcp.id)) || !unique(plan.mcps.map((mcp) => mcp.registrationName.toLowerCase())) || plan.files.reduce((size, file) => size + file.size, 0) > 524288e3 || plan.kind === "SKILL" && (plan.skills.length !== 1 || plan.mcps.length !== 0)) return invalid();
101
+ if (roots.some((root, index) => roots.some((other, otherIndex) => index !== otherIndex && (root === other || root.startsWith(`${other}/`))))) return invalid();
102
+ if (plan.skills.some((skill) => !plan.files.some((file) => file.path === `${skill.root}/SKILL.md`)) || plan.files.some((file) => !plan.skills.some((skill) => file.path.startsWith(`${skill.root}/`)))) return invalid();
103
+ if (plan.mcps.some((mcp) => {
104
+ const sensitiveKeys = new Set(mcp.inputs.filter((input) => input.sensitive).map((input) => input.key.toLowerCase()));
105
+ return hasLiteralSecret(mcp.config, sensitiveKeys) || hasLiteralSecret(mcp.runtime, sensitiveKeys) || !unique(mcp.inputs.map((input) => input.key));
106
+ })) return invalid();
107
+ return plan;
108
+ }
109
+ function canonical(value) {
110
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
111
+ if (value && typeof value === "object") return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
112
+ return JSON.stringify(value);
113
+ }
114
+ function hash(value) {
115
+ return createHash("sha256").update(value).digest("hex");
116
+ }
117
+ function stableManifest(plan) {
118
+ const { id, expiresAt, ...stable } = plan;
119
+ return stable;
120
+ }
121
+ function verifyPlan(value) {
122
+ const plan = parseContentPlan(value);
123
+ const { manifestDigest, ...content } = stableManifest(plan);
124
+ if (hash(canonical(content)) !== manifestDigest) throw new Error("内容计划摘要不匹配。");
125
+ for (const skill of plan.skills) if (hash(canonical(plan.files.filter((file) => file.path.startsWith(`${skill.root}/`)))) !== skill.digest) throw new Error("技能摘要不匹配。");
126
+ for (const mcp of plan.mcps) {
127
+ const { digest, ...entry } = mcp;
128
+ if (hash(canonical(entry)) !== digest) throw new Error("MCP 摘要不匹配。");
129
+ }
130
+ return plan;
131
+ }
132
+ export { canonical, hash, stableManifest, verifyPlan };
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { dirname, parse, resolve } from "node:path";
4
+ import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
5
+ async function checkPath(filename) {
6
+ let current = resolve(filename);
7
+ while (current !== parse(current).root) {
8
+ try {
9
+ const stat = await lstat(current);
10
+ if (stat.isSymbolicLink() || current === resolve(filename) && (!stat.isFile() || stat.nlink > 1)) throw new Error("配置路径含符号链接、特殊文件或硬链接,拒绝写入。");
11
+ } catch (error) {
12
+ if (error.code !== "ENOENT") throw error;
13
+ }
14
+ current = dirname(current);
15
+ }
16
+ }
17
+ async function readConfig(filename) {
18
+ await checkPath(filename);
19
+ try {
20
+ const handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
21
+ try {
22
+ const stat = await handle.stat();
23
+ if (!stat.isFile() || stat.size > 2097152) throw new Error("配置文件不是普通文件或超过 2 MiB。");
24
+ return await handle.readFile("utf8");
25
+ } finally {
26
+ await handle.close();
27
+ }
28
+ } catch (error) {
29
+ if (error.code === "ENOENT") return;
30
+ throw error;
31
+ }
32
+ }
33
+ async function identity(filename) {
34
+ try {
35
+ const stat = await lstat(filename, { bigint: true });
36
+ return `${stat.dev}:${stat.ino}:${stat.mode}:${stat.mtimeNs}:${stat.size}`;
37
+ } catch (error) {
38
+ if (error.code === "ENOENT") return;
39
+ throw error;
40
+ }
41
+ }
42
+ async function writeConfig(filename, previous, next, expectedIdentity) {
43
+ if (previous === next) return;
44
+ await checkPath(filename);
45
+ const originalIdentity = expectedIdentity ?? await identity(filename);
46
+ if (previous !== void 0 && ((await lstat(filename)).mode & 128) === 0) throw new Error("配置文件只读,不放宽其权限。");
47
+ await mkdir(dirname(filename), {
48
+ recursive: true,
49
+ mode: 448
50
+ });
51
+ const lockPath = `${filename}.bfi-mcp.lock`;
52
+ const temporary = `${filename}.bfi-mcp-${randomUUID()}.tmp`;
53
+ const backup = previous === void 0 ? void 0 : `${filename}.bfi-mcp-${Date.now()}-${randomUUID()}.bak`;
54
+ let lock;
55
+ try {
56
+ lock = await open(lockPath, "wx", 384);
57
+ } catch {
58
+ throw new Error("无法取得配置写入锁。可能有其它安装正在进行;请核对残留 .bfi-mcp.lock 后重试。");
59
+ }
60
+ try {
61
+ await checkPath(filename);
62
+ if (await readConfig(filename) !== previous || await identity(filename) !== originalIdentity) throw new Error("配置已被其它进程修改,请重新预览并重试。");
63
+ if (backup) {
64
+ const handle = await open(backup, "wx", 384);
65
+ try {
66
+ await handle.writeFile(previous, "utf8");
67
+ await handle.sync();
68
+ } finally {
69
+ await handle.close();
70
+ }
71
+ }
72
+ const handle = await open(temporary, "wx", 384);
73
+ try {
74
+ await handle.writeFile(next, "utf8");
75
+ await handle.sync();
76
+ } finally {
77
+ await handle.close();
78
+ }
79
+ await checkPath(filename);
80
+ if (await readConfig(filename) !== previous || await identity(filename) !== originalIdentity) throw new Error("写入前检测到并发修改,已取消。");
81
+ await rename(temporary, filename);
82
+ return backup;
83
+ } finally {
84
+ await unlink(temporary).catch(() => {});
85
+ await lock.close().catch(() => {});
86
+ await unlink(lockPath).catch(() => {});
87
+ }
88
+ }
89
+ export { checkPath, identity, readConfig, writeConfig };
@@ -248,9 +248,9 @@ function checkCredentials(file) {
248
248
  "id_ed25519",
249
249
  ".credentials.json",
250
250
  "credentials"
251
- ].includes(basename) || lower.startsWith(".git/") || lower.includes("/.git/")) throw new Error("Archive contains a credential or repository-private file");
251
+ ].includes(basename) || lower.startsWith(".git/") || lower.includes("/.git/")) throw new Error(`Archive contains a credential or repository-private file (${file.path})`);
252
252
  const text = file.content.toString("utf8");
253
- checkCredentialText(text);
253
+ checkCredentialText(file.path, text);
254
254
  if (lower.endsWith(".json")) {
255
255
  let decoded;
256
256
  try {
@@ -258,18 +258,62 @@ function checkCredentials(file) {
258
258
  } catch {
259
259
  return;
260
260
  }
261
- checkCredentialText(JSON.stringify(decoded));
261
+ checkCredentialText(file.path, JSON.stringify(decoded));
262
262
  }
263
263
  }
264
- function checkCredentialText(text) {
265
- if (/AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/u.test(text)) throw new Error("Archive contains a credential literal");
266
- for (const match of text.matchAll(/(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|password|token|authorization|x-pm-[as]k)["']?\s*[:=]\s*["']?(?:Bearer\s+)?([A-Za-z0-9_/-]{12,})/giu)) if (![
267
- "YOUR_API_KEY",
268
- "YOUR_ACCESS_TOKEN",
269
- "YOUR_PASSWORD",
270
- "REPLACE_ME",
271
- "placeholder"
272
- ].includes(match[1])) throw new Error("Archive contains a possible credential literal");
264
+ function checkCredentialText(path, text) {
265
+ if (/(?<![A-Za-z0-9_-])(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,})(?![A-Za-z0-9_-])|-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/u.test(text)) throw new Error(`Archive contains a credential literal (${path})`);
266
+ const regions = credentialCodeRegions(path, text);
267
+ const expression = /(?<![A-Za-z0-9_$])(?:[A-Za-z][A-Za-z0-9_-]*)?(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|password|token|authorization|x-pm-[as]k)["']?\s*[:=]\s*(?:"((?:\\.|[^\\"\r\n]){12,})"|'((?:\\.|[^\\'\r\n]){12,})'|(?:Bearer\s+)?([A-Za-z0-9_./+=!@#$%^&*?:<>|~-]{12,})(?=\s*(?:$|[,;}\r\n"'])))/giu;
268
+ let regionIndex = 0;
269
+ for (const match of text.matchAll(expression)) {
270
+ while (regionIndex < regions.length && regions[regionIndex].end <= match.index) regionIndex++;
271
+ const region = regions[regionIndex];
272
+ const value = match[1] ?? match[2] ?? match[3];
273
+ if (region && region.start <= match.index && match.index + match[0].length <= region.end && match[3] !== void 0 && !/\bBearer\s/iu.test(match[0]) && !value.startsWith("eyJ") && /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/u.test(value)) continue;
274
+ const literal = value.replace(/^Bearer\s+/iu, "");
275
+ if (/^(?:\$\{[^{}\r\n]+\}|\$[A-Za-z_][A-Za-z0-9_]*)$/u.test(literal)) continue;
276
+ if ([
277
+ "YOUR_API_KEY",
278
+ "YOUR_ACCESS_TOKEN",
279
+ "YOUR_PASSWORD",
280
+ "REPLACE_ME"
281
+ ].includes(literal.replace(/-/gu, "_").toUpperCase()) || [
282
+ "placeholder",
283
+ "literal_token",
284
+ "加密后的clientSecret"
285
+ ].includes(literal)) continue;
286
+ throw new Error(`Archive contains a possible credential literal (${path})`);
287
+ }
288
+ }
289
+ function credentialCodeRegions(path, text) {
290
+ if (/\.(?:[cm]?[jt]sx?|py|java|go|rs)$/iu.test(path)) return [{
291
+ start: 0,
292
+ end: text.length
293
+ }];
294
+ if (!/\.(?:md|markdown)$/iu.test(path)) return [];
295
+ const regions = [];
296
+ let opening;
297
+ for (const fence of text.matchAll(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)\r?$/gmu)) {
298
+ const marker = fence[1], language = fence[2].trim();
299
+ if (!opening) opening = {
300
+ marker,
301
+ start: fence.index + fence[0].length,
302
+ code: /^(?:[cm]?js|javascript|jsx|ts|typescript|tsx|py|python|java|go|rs|rust)$/iu.test(language)
303
+ };
304
+ else if (marker[0] === opening.marker[0] && marker.length >= opening.marker.length && !language) {
305
+ if (opening.code) regions.push({
306
+ start: opening.start,
307
+ end: fence.index
308
+ });
309
+ opening = void 0;
310
+ }
311
+ }
312
+ if (opening?.code) regions.push({
313
+ start: opening.start,
314
+ end: text.length
315
+ });
316
+ return regions;
273
317
  }
274
318
  function treeDigest(files) {
275
319
  const digest = createHash("sha256");
@@ -532,4 +576,110 @@ var PluginRegistryClient = class {
532
576
  return this.bytes(`${this.base(namespace, slug)}/plugin/releases/${segment(version)}/download`, maximum);
533
577
  }
534
578
  };
535
- export { PluginInstallRecords, PluginRegistryClient, inspectPluginArchive, materializePluginTree, normalizeRegistry, pluginArchiveLimits, pluginInstallKey, pluginSlotKey, selectPluginRoot, sha256, treeDigest };
579
+ const pluginTargets = [
580
+ "claude-code",
581
+ "codex",
582
+ "zcode",
583
+ "codebuddy",
584
+ "qwen-code"
585
+ ];
586
+ const isPluginArchive = (value) => /\.(zip|tgz|tar\.gz)$/iu.test(value);
587
+ const isPluginVersion = (value) => /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.test(value);
588
+ function parsePackageIntent(args) {
589
+ const output = [...args];
590
+ let mode = "skills";
591
+ const occurrences = args.filter((value) => value === "--as" || value.startsWith("--as="));
592
+ if (occurrences.length) {
593
+ const index = output.indexOf("--as");
594
+ const requested = output[index + 1];
595
+ if (occurrences.length !== 1 || index < 0 || !["skills", "plugin"].includes(requested ?? "")) throw new Error("Use --as skills or --as plugin exactly once; upgrade bfi if this option is unavailable");
596
+ mode = requested;
597
+ output.splice(index, 2);
598
+ }
599
+ if (mode === "skills" && (output.some((value) => [
600
+ "--plan",
601
+ "--export",
602
+ "--prepare-native",
603
+ "--scope",
604
+ "--root",
605
+ "--operation"
606
+ ].includes(value) || /^(--plan|--export|--prepare-native|--scope|--root|--operation)=/u.test(value)) || isPluginArchive(output[1] ?? ""))) throw new Error("Plugin archives and plugin options require explicit --as plugin; no skills were installed");
607
+ return {
608
+ mode,
609
+ args: output
610
+ };
611
+ }
612
+ function parsePluginArguments(args) {
613
+ const command = {
614
+ install: "install",
615
+ i: "install",
616
+ update: "update",
617
+ up: "update",
618
+ uninstall: "uninstall",
619
+ rm: "uninstall",
620
+ list: "list",
621
+ ls: "list"
622
+ }[args[0] ?? ""];
623
+ if (!command) throw new Error("Plugin intent supports install, update, uninstall and list");
624
+ const result = {
625
+ command,
626
+ scope: "project",
627
+ planOnly: false,
628
+ json: false
629
+ };
630
+ const seen = /* @__PURE__ */ new Set();
631
+ const values = /* @__PURE__ */ new Set([
632
+ "--agent",
633
+ "--version",
634
+ "--scope",
635
+ "--root",
636
+ "--export",
637
+ "--prepare-native",
638
+ "--operation",
639
+ "--registry",
640
+ "--token",
641
+ "--namespace"
642
+ ]);
643
+ for (let index = 1; index < args.length; index++) {
644
+ const option = args[index];
645
+ if (!option.startsWith("-")) {
646
+ if (result.source) throw new Error("Specify exactly one plugin source");
647
+ result.source = option;
648
+ continue;
649
+ }
650
+ const flag = option === "-g" ? "--global" : option === "-y" ? "--yes" : option;
651
+ if (seen.has(flag)) throw new Error(`Duplicate plugin option: ${flag}`);
652
+ seen.add(flag);
653
+ if (values.has(flag)) {
654
+ const value = args[++index];
655
+ if (value === void 0 || value.startsWith("-") || /[\u0000-\u001f\u007f]/u.test(value)) throw new Error(`Missing or invalid value for ${flag}`);
656
+ if (flag === "--agent") {
657
+ if (!pluginTargets.includes(value)) throw new Error("Unknown native plugin target");
658
+ result.agent = value;
659
+ } else if (flag === "--version") {
660
+ if (!isPluginVersion(value)) throw new Error("Plugin version must be exact SemVer, not latest or a range");
661
+ result.version = value;
662
+ } else if (flag === "--scope") {
663
+ if (!["project", "user"].includes(value)) throw new Error("Plugin scope must be project or user");
664
+ result.scope = value;
665
+ } else if (flag === "--root") result.root = value;
666
+ else if (flag === "--export") result.exportPath = value;
667
+ else if (flag === "--prepare-native") result.preparePath = value;
668
+ else if (flag === "--operation") result.operationId = value;
669
+ } else if (flag === "--global") result.scope = "user";
670
+ else if (flag === "--plan") result.planOnly = true;
671
+ else if (flag === "--json") result.json = true;
672
+ else if (flag === "--yes") result.confirmed = true;
673
+ else throw new Error(`Unknown plugin option: ${flag}; upgrade bfi if needed`);
674
+ }
675
+ if (seen.has("--scope") && seen.has("--global")) throw new Error("Use --scope or --global, not both");
676
+ result.scopeExplicit = seen.has("--scope") || seen.has("--global");
677
+ if (command !== "list" && !result.source && !(command === "uninstall" && result.operationId)) throw new Error("Specify @namespace/package or a local zip/tgz path");
678
+ if (result.source && !/^@[^/\s]+\/[^/\s]+$/u.test(result.source) && !isPluginArchive(result.source)) throw new Error("Use a namespace-qualified @namespace/package reference or local archive");
679
+ if (result.planOnly && result.exportPath) throw new Error("Plan and export are separate operations");
680
+ if (result.preparePath && (result.planOnly || result.exportPath || command !== "install")) throw new Error("Native handoff, plan and export are separate install operations");
681
+ if (result.root !== void 0 && !isPluginArchive(result.source ?? "") || result.operationId && command !== "uninstall" || (result.exportPath || result.root !== void 0) && command !== "install") throw new Error("Plugin option is invalid for this operation");
682
+ if (command === "list" && (result.source || result.version || result.planOnly || result.exportPath || result.operationId)) throw new Error("Invalid plugin list options");
683
+ return result;
684
+ }
685
+ export { PluginInstallRecords, PluginRegistryClient, inspectPluginArchive, isPluginArchive, isPluginVersion, materializePluginTree, normalizeRegistry, parsePackageIntent, parsePluginArguments, pluginArchiveLimits, pluginInstallKey, pluginSlotKey, pluginTargets, selectPluginRoot, sha256, treeDigest };