fde2e-bu 0.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin.js +13 -0
- package/package.json +30 -6
- package/src/args.mjs +92 -0
- package/src/cli.mjs +445 -0
- package/src/configdir.mjs +16 -0
- package/src/mcp/bridge.mjs +208 -0
- package/src/mcp/commands-snapshot.json +340 -0
- package/src/mcp/config-cli.mjs +172 -0
- package/src/mcp/config.mjs +257 -0
- package/src/mcp/http.mjs +250 -0
- package/src/mcp/schema.mjs +175 -0
- package/src/mcp/serve.mjs +203 -0
- package/src/misc.mjs +229 -0
- package/src/nm/bind.mjs +229 -0
- package/src/nm/host.mjs +429 -0
- package/src/nm/update.mjs +727 -0
- package/src/skills/frontmatter.mjs +150 -0
- package/src/skills/skills.mjs +323 -0
- package/index.js +0 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// SKILL.md frontmatter 解析器(task 2.1/2.2)——手写最小 YAML 子集,不引 yaml 依赖
|
|
2
|
+
// (零依赖原则;agentskills.io frontmatter 实际用到的语法面极小:
|
|
3
|
+
// 顶层标量键值 + 一~两层嵌套 map + 行内 flow 数组,足够覆盖 name/description/
|
|
4
|
+
// version/metadata.version 的渐进披露读取)。
|
|
5
|
+
//
|
|
6
|
+
// 支持的子集:
|
|
7
|
+
// key: value 标量(裸值 / 单引号 / 双引号字符串;行尾 " #" 注释)
|
|
8
|
+
// key: 空值或嵌套块(下一行更深缩进 → 子 map)
|
|
9
|
+
// key: [a, b, c] 行内 flow 数组(字符串元素;引号内逗号不切分——r1 状态机)
|
|
10
|
+
// 键字符=\w+CJK+.-(r1:允许非 ASCII 键,如 `名称:`)
|
|
11
|
+
// '#' 开头整行注释、空行跳过
|
|
12
|
+
// 其余(多行字符串|、锚点、复杂 flow 等)报 PARSE_ERROR——渐进披露不需要。
|
|
13
|
+
|
|
14
|
+
/** @returns {{ ok: true, data: Record<string, unknown> } | { ok: false, reason: string }} */
|
|
15
|
+
export function parseFrontmatter(text) {
|
|
16
|
+
if (typeof text !== 'string') return { ok: false, reason: '输入不是字符串' };
|
|
17
|
+
const lines = text.split('\n').map((l) => l.replace(/\r$/, ''));
|
|
18
|
+
if (lines[0] === undefined || lines[0].trim() !== '---') {
|
|
19
|
+
return { ok: false, reason: '首行不是 --- 分隔符(无 frontmatter)' };
|
|
20
|
+
}
|
|
21
|
+
let end = -1;
|
|
22
|
+
for (let i = 1; i < lines.length; i++) {
|
|
23
|
+
const t = lines[i].trim();
|
|
24
|
+
if (t === '---' || t === '...') { end = i; break; }
|
|
25
|
+
if (t === '') continue; // 分隔符前允许空行
|
|
26
|
+
}
|
|
27
|
+
if (end === -1) return { ok: false, reason: 'frontmatter 未闭合(缺第二个 --- 行)' };
|
|
28
|
+
try {
|
|
29
|
+
return { ok: true, data: parseYamlSubset(lines.slice(1, end)) };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return { ok: false, reason: err.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const indentOf = (s) => { const m = /^ */.exec(s); return m ? m[0].length : 0; };
|
|
36
|
+
const isSignificant = (l) => l.trim() !== '' && !l.trim().startsWith('#');
|
|
37
|
+
|
|
38
|
+
/** 标量解析:剥引号(支持双/单引号与 \" 转义)、剥行尾注释;一律返回字符串。 */
|
|
39
|
+
function parseScalar(raw) {
|
|
40
|
+
let s = raw.trim();
|
|
41
|
+
if (s.startsWith('"')) {
|
|
42
|
+
const m = /^"((?:[^"\\]|\\.)*)"/.exec(s);
|
|
43
|
+
if (!m) throw new Error(`双引号字符串未闭合:${raw}`);
|
|
44
|
+
return m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
45
|
+
}
|
|
46
|
+
if (s.startsWith("'")) {
|
|
47
|
+
const m = /^'([^']*)'/.exec(s);
|
|
48
|
+
if (!m) throw new Error(`单引号字符串未闭合:${raw}`);
|
|
49
|
+
return m[1];
|
|
50
|
+
}
|
|
51
|
+
const hash = s.indexOf(' #');
|
|
52
|
+
if (hash !== -1) s = s.slice(0, hash).trim(); // 行尾注释(裸值才会出现 " #")
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 行内 flow 数组:[a, "b", 'c'] → string[]。
|
|
57
|
+
* r1:逗号切分走引号状态机——引号内的逗号(如 tags: ["a,b", c])不得切开。 */
|
|
58
|
+
function parseFlowArray(raw) {
|
|
59
|
+
const s = raw.trim();
|
|
60
|
+
if (!s.endsWith(']')) throw new Error(`flow 数组未闭合:${raw}`);
|
|
61
|
+
const inner = s.slice(1, -1).trim();
|
|
62
|
+
if (inner === '') return [];
|
|
63
|
+
return splitFlowElements(inner).map((el) => parseScalar(el));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 引号保护切分:单/双引号内的分隔符与转义(\")原样保留,引号外逗号才分段。 */
|
|
67
|
+
function splitFlowElements(inner) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
let cur = '';
|
|
70
|
+
let quote = null; // null | '"' | "'"
|
|
71
|
+
for (let i = 0; i < inner.length; i++) {
|
|
72
|
+
const ch = inner[i];
|
|
73
|
+
if (quote !== null) {
|
|
74
|
+
cur += ch;
|
|
75
|
+
if (ch === '\\' && quote === '"' && i + 1 < inner.length) { cur += inner[++i]; continue; }
|
|
76
|
+
if (ch === quote) quote = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
|
80
|
+
if (ch === ',') { parts.push(cur); cur = ''; continue; }
|
|
81
|
+
cur += ch;
|
|
82
|
+
}
|
|
83
|
+
parts.push(cur);
|
|
84
|
+
return parts.map((p) => p.trim()).filter((p) => p !== '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 缩进递归下降解析(行数组 → 普通对象)。
|
|
89
|
+
* @param {string[]} lines
|
|
90
|
+
* @param {number} [baseIndent] 当前块基准缩进
|
|
91
|
+
*/
|
|
92
|
+
function parseYamlSubset(lines, baseIndent = 0) {
|
|
93
|
+
const map = {};
|
|
94
|
+
let i = 0;
|
|
95
|
+
const nextSignificant = (from) => {
|
|
96
|
+
for (let j = from; j < lines.length; j++) if (isSignificant(lines[j])) return j;
|
|
97
|
+
return -1;
|
|
98
|
+
};
|
|
99
|
+
while (i < lines.length) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (!isSignificant(line)) { i++; continue; }
|
|
102
|
+
const ind = indentOf(line);
|
|
103
|
+
if (ind < baseIndent) break;
|
|
104
|
+
if (ind > baseIndent) throw new Error(`意外缩进(无宿主键):${line.trim()}`);
|
|
105
|
+
// r1:键允许非 ASCII(技能作者用中文键,如 `名称: …`);\w=[A-Za-z0-9_],另放行 CJK 与 .-
|
|
106
|
+
const m = /^([\w\u4e00-\u9fff.-]+):\s*(.*)$/.exec(line.trim());
|
|
107
|
+
if (!m) throw new Error(`无法解析的 frontmatter 行:${line.trim()}`);
|
|
108
|
+
const key = m[1];
|
|
109
|
+
const rest = m[2];
|
|
110
|
+
if (rest === '') {
|
|
111
|
+
const nxt = nextSignificant(i + 1);
|
|
112
|
+
if (nxt !== -1 && indentOf(lines[nxt]) > baseIndent) {
|
|
113
|
+
map[key] = parseYamlSubset(lines.slice(nxt), indentOf(lines[nxt]));
|
|
114
|
+
// 块值消费到「下一行首」——continue 免得多跳一行(nxt+count 已指向未消费行)
|
|
115
|
+
i = nxt + countConsumed(lines, nxt, indentOf(lines[nxt]));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
map[key] = null;
|
|
119
|
+
} else if (rest.trim().startsWith('[')) {
|
|
120
|
+
map[key] = parseFlowArray(rest);
|
|
121
|
+
} else {
|
|
122
|
+
map[key] = parseScalar(rest);
|
|
123
|
+
}
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
return map;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 子块被递归解析后,计算其在原数组中占用的行数(到下一个不缩进行或末尾)。 */
|
|
130
|
+
function countConsumed(lines, start, childIndent) {
|
|
131
|
+
let n = 0;
|
|
132
|
+
for (let j = start; j < lines.length; j++) {
|
|
133
|
+
if (isSignificant(lines[j]) && indentOf(lines[j]) < childIndent) break;
|
|
134
|
+
n++;
|
|
135
|
+
}
|
|
136
|
+
return n;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 渐进披露取值:version 取顶层,缺失回落 metadata.version(agentskills 规范位置)。 */
|
|
140
|
+
export function disclosedFields(data) {
|
|
141
|
+
const metadata = data && typeof data.metadata === 'object' && data.metadata !== null ? data.metadata : {};
|
|
142
|
+
return {
|
|
143
|
+
name: typeof data?.name === 'string' ? data.name : '',
|
|
144
|
+
description: typeof data?.description === 'string' ? data.description : '',
|
|
145
|
+
version:
|
|
146
|
+
typeof data?.version === 'string' && data.version !== '' ? data.version
|
|
147
|
+
: typeof metadata.version === 'string' && metadata.version !== '' ? metadata.version
|
|
148
|
+
: '',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// fde2e-bu skills —— 技能包管理(tasks 2.1/2.2/2.3)。
|
|
2
|
+
// 事实标准 agentskills.io:<skill>/SKILL.md + name/description 前置渐进披露;
|
|
3
|
+
// 目录约定收敛 .agents/skills(默认写),兼容 .claude/skills。
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync, realpathSync, rmSync, renameSync, mkdirSync, cpSync, mkdtempSync } from 'node:fs';
|
|
5
|
+
import { join, resolve, relative, isAbsolute, dirname, basename } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { parse, UsageError, assertFlags, assertNoPositionals } from '../args.mjs';
|
|
9
|
+
import { parseFrontmatter, disclosedFields } from './frontmatter.mjs';
|
|
10
|
+
|
|
11
|
+
export const SKILL_FILE = 'SKILL.md';
|
|
12
|
+
export const DEST_DIRS = { agents: '.agents/skills', claude: '.claude/skills' };
|
|
13
|
+
|
|
14
|
+
/** 非法技能名(r1 P0-1):exit 1。路径穿越/分隔符/绝对路径在此全被拒。 */
|
|
15
|
+
export class IllegalSkillNameError extends Error {
|
|
16
|
+
constructor(name) {
|
|
17
|
+
super(`非法技能名:${name}(须为小写字母数字连字符,且不得含路径分隔符/…/绝对路径)`);
|
|
18
|
+
this.name = 'IllegalSkillNameError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** containment 判定:target 是否仍在 base 目录内(path.relative 不以 .. 开头且非绝对)。
|
|
23
|
+
* 双保险防线——NAME_RE 已拒穿越形状,此函数兜住未来重构引入的绕过。 */
|
|
24
|
+
export function isContained(base, target) {
|
|
25
|
+
const rel = relative(resolve(base), resolve(target));
|
|
26
|
+
return !rel.startsWith('..') && !isAbsolute(rel);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// C1 flag 面(P2-6):按子命令收窄——`skills list --force` 这类此前被解析后静默
|
|
30
|
+
// 忽略的组合,现在一律用法错误 exit 2。validate 接受位置参数(待校验路径),
|
|
31
|
+
// list 不接受(本工作区扫描)。
|
|
32
|
+
const SUB_OPTIONS = {
|
|
33
|
+
list: { json: { type: 'boolean' }, root: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
|
|
34
|
+
add: { dest: { type: 'string' }, skill: { type: 'string' }, force: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
35
|
+
remove: { dest: { type: 'string' }, all: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
36
|
+
validate: { help: { type: 'boolean', short: 'h' } },
|
|
37
|
+
};
|
|
38
|
+
const SUB_FLAGS = {
|
|
39
|
+
list: ['json', 'root', 'help'],
|
|
40
|
+
add: ['dest', 'skill', 'force', 'help'],
|
|
41
|
+
remove: ['dest', 'all', 'help'],
|
|
42
|
+
validate: ['help'],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 从 startDir 逐级向上找最近的含 .agents/skills 或 .claude/skills 的目录。 */
|
|
46
|
+
export function findSkillsWorkspace(startDir) {
|
|
47
|
+
let dir = resolve(startDir);
|
|
48
|
+
for (;;) {
|
|
49
|
+
const roots = Object.entries(DEST_DIRS)
|
|
50
|
+
.filter(([, rel]) => isDir(join(dir, rel)))
|
|
51
|
+
.map(([name, rel]) => ({ name, path: join(dir, rel), workspace: dir }));
|
|
52
|
+
if (roots.length > 0) return { workspace: dir, roots };
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir) break;
|
|
55
|
+
dir = parent;
|
|
56
|
+
}
|
|
57
|
+
return { workspace: null, roots: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
|
|
61
|
+
|
|
62
|
+
/** 单个技能目录 → 渐进披露条目(只读 frontmatter,不读正文)。 */
|
|
63
|
+
export function readSkillEntry(rootName, skillDir) {
|
|
64
|
+
const file = join(skillDir, SKILL_FILE);
|
|
65
|
+
const entry = { source: rootName, dir: skillDir, name: basename(skillDir), version: '', description: '', error: null };
|
|
66
|
+
if (!existsSync(file)) {
|
|
67
|
+
entry.error = '缺 SKILL.md';
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
const parsed = parseFrontmatter(readFileSync(file, 'utf8'));
|
|
71
|
+
if (!parsed.ok) {
|
|
72
|
+
entry.error = `frontmatter 解析失败:${parsed.reason}`;
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
const f = disclosedFields(parsed.data);
|
|
76
|
+
entry.name = f.name || entry.name;
|
|
77
|
+
entry.description = f.description;
|
|
78
|
+
entry.version = f.version;
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 扫描全部技能(list/validate 共用)。 */
|
|
83
|
+
export function scanSkills(startDir) {
|
|
84
|
+
const { workspace, roots } = findSkillsWorkspace(startDir);
|
|
85
|
+
const skills = [];
|
|
86
|
+
for (const root of roots) {
|
|
87
|
+
for (const ent of readdirSync(root.path, { withFileTypes: true })) {
|
|
88
|
+
if (!ent.isDirectory() || ent.name.startsWith('.') || ent.name === 'node_modules') continue;
|
|
89
|
+
skills.push(readSkillEntry(root.name, join(root.path, ent.name)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { workspace, roots, skills };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------- validate(task 2.3) ----------
|
|
96
|
+
|
|
97
|
+
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
98
|
+
|
|
99
|
+
/** agentskills.io 规则集 → { pass, failures: string[] }。
|
|
100
|
+
* dirNameCheck=是否校验 name=目录名(安装后布局约束);add 源校验时关闭——
|
|
101
|
+
* 源目录名(临时克隆/重命名)可合法异于 name,安装以 name 建目录、一致性由构造保证。 */
|
|
102
|
+
export function validateSkillDir(skillDir, { dirNameCheck = true } = {}) {
|
|
103
|
+
const failures = [];
|
|
104
|
+
const file = join(skillDir, SKILL_FILE);
|
|
105
|
+
if (!existsSync(file)) return { pass: false, failures: ['缺 SKILL.md'] };
|
|
106
|
+
const parsed = parseFrontmatter(readFileSync(file, 'utf8'));
|
|
107
|
+
if (!parsed.ok) return { pass: false, failures: [`frontmatter 解析失败:${parsed.reason}`] };
|
|
108
|
+
const { name, description } = disclosedFields(parsed.data);
|
|
109
|
+
if (!name) failures.push('name 缺失');
|
|
110
|
+
else {
|
|
111
|
+
if (name.length > 64) failures.push(`name 超长(${name.length} > 64)`);
|
|
112
|
+
if (!NAME_RE.test(name)) failures.push(`name 不合小写连字符规则:${name}`);
|
|
113
|
+
if (dirNameCheck && name !== basename(resolve(skillDir))) failures.push(`name(${name})≠ 目录名(${basename(resolve(skillDir))})`);
|
|
114
|
+
}
|
|
115
|
+
if (!description) failures.push('description 缺失');
|
|
116
|
+
else if (description.length > 1024) failures.push(`description 超长(${description.length} > 1024)`);
|
|
117
|
+
return { pass: failures.length === 0, failures };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function runValidate(positionals, values) {
|
|
121
|
+
// r1 P1-1:map 回调透传 (v,i,arr) 给 resolve 会被当多参 join 解析——必须单参显式包一层
|
|
122
|
+
const targets = positionals.length > 0 ? positionals.map((p) => resolve(p)) : null;
|
|
123
|
+
let results;
|
|
124
|
+
if (targets) {
|
|
125
|
+
results = targets.map((dir) => ({ dir, ...validateSkillDir(dir) }));
|
|
126
|
+
} else {
|
|
127
|
+
const { skills } = scanSkills(process.cwd());
|
|
128
|
+
if (skills.length === 0) {
|
|
129
|
+
console.log('未发现技能目录(向上未找到 .agents/skills / .claude/skills,或目录为空)');
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
results = skills.map((s) => ({ dir: s.dir, ...validateSkillDir(s.dir) }));
|
|
133
|
+
}
|
|
134
|
+
let failed = 0;
|
|
135
|
+
for (const r of results) {
|
|
136
|
+
if (r.pass) console.log(`PASS ${r.dir}`);
|
|
137
|
+
else {
|
|
138
|
+
failed++;
|
|
139
|
+
console.error(`FAIL ${r.dir}`);
|
|
140
|
+
for (const f of r.failures) console.error(` - ${f}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
console.log(`校验 ${results.length} 个技能:${results.length - failed} 通过 / ${failed} 失败`);
|
|
144
|
+
return failed > 0 ? 1 : 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- list(task 2.1) ----------
|
|
148
|
+
|
|
149
|
+
function runList(values) {
|
|
150
|
+
const startDir = values.root ? resolve(String(values.root)) : process.cwd();
|
|
151
|
+
const { workspace, roots, skills } = scanSkills(startDir);
|
|
152
|
+
if (values.json) {
|
|
153
|
+
console.log(JSON.stringify({ workspace, roots: roots.map((r) => r.path), skills }, null, 2));
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (roots.length === 0) {
|
|
157
|
+
console.log(`未找到技能目录(自 ${startDir} 向上无 .agents/skills / .claude/skills)`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
console.log(`工作区:${workspace}`);
|
|
161
|
+
for (const r of roots) console.log(`目录源:${r.path}(${r.name})`);
|
|
162
|
+
if (skills.length === 0) {
|
|
163
|
+
console.log('(无已装技能)');
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
const nameW = Math.max(4, ...skills.map((s) => s.name.length));
|
|
167
|
+
const verW = Math.max(7, ...skills.map((s) => (s.version || '-').length));
|
|
168
|
+
console.log(`${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} SRC DESCRIPTION`);
|
|
169
|
+
for (const s of skills) {
|
|
170
|
+
const desc = (s.error ? `⚠ ${s.error}` : s.description).replace(/\s+/g, ' ');
|
|
171
|
+
const short = desc.length > 64 ? `${desc.slice(0, 63)}…` : desc;
|
|
172
|
+
console.log(`${s.name.padEnd(nameW)} ${(s.version || '-').padEnd(verW)} ${s.source === 'agents' ? 'ag' : 'cl'} ${short}`);
|
|
173
|
+
}
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------- add / remove(task 2.2) ----------
|
|
178
|
+
|
|
179
|
+
const COPY_EXCLUDE = new Set(['.git', 'node_modules', '__pycache__', '.DS_Store']);
|
|
180
|
+
|
|
181
|
+
/** 拷贝安装(r1 P1-2:目标已存在时先拷临时目录、成功后再 rm+mv 原子替换——
|
|
182
|
+
* 中断不丢既有安装;EXDEV 时退回 cp 覆盖)。 */
|
|
183
|
+
function installSkill(srcDir, target) {
|
|
184
|
+
const copyOpts = { recursive: true, filter: (s) => !COPY_EXCLUDE.has(basename(s)) };
|
|
185
|
+
if (isDir(target)) {
|
|
186
|
+
const staging = mkdtempSync(join(tmpdir(), 'fde2e-bu-install-'));
|
|
187
|
+
try {
|
|
188
|
+
const staged = join(staging, basename(target));
|
|
189
|
+
cpSync(srcDir, staged, copyOpts); // 先拷(失败即中止,原安装无损)
|
|
190
|
+
rmSync(target, { recursive: true, force: true });
|
|
191
|
+
try {
|
|
192
|
+
renameSync(staged, target);
|
|
193
|
+
} catch {
|
|
194
|
+
// 跨设备 rename(EXDEV 等):退回 cp(此时旧目标已备份在 staged 失败重来风险窗口最小化)
|
|
195
|
+
cpSync(staged, target, copyOpts);
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
rmSync(staging, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
203
|
+
cpSync(srcDir, target, copyOpts);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 源解析(r1 item 12):本地路径直取;git URL 浅克隆到临时目录。
|
|
207
|
+
* 返回 { dir, cleanup }——cleanup 回收克隆临时目录(调用方 finally 必调);
|
|
208
|
+
* --skill 子目录须 containment 于克隆根内(拒绝 ../../.. 越界与绝对路径)。 */
|
|
209
|
+
function resolveSource(src, skillSub) {
|
|
210
|
+
if (/^(https?|file):\/\//.test(src)) {
|
|
211
|
+
const tmp = mkdtempSync(join(tmpdir(), 'fde2e-bu-skill-'));
|
|
212
|
+
const cleanup = () => rmSync(tmp, { recursive: true, force: true });
|
|
213
|
+
let dir = tmp;
|
|
214
|
+
try {
|
|
215
|
+
if (skillSub) {
|
|
216
|
+
dir = resolve(tmp, skillSub);
|
|
217
|
+
if (!isContained(tmp, dir)) throw new IllegalSkillNameError(`--skill ${skillSub}(越出克隆仓库根)`);
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
execFileSync('git', ['clone', '--depth', '1', src, tmp], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
throw new Error(`git clone 失败(${src}):${err.stderr?.toString().trim() || err.message}`);
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
cleanup();
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
return { dir, cleanup };
|
|
229
|
+
}
|
|
230
|
+
return { dir: resolve(src), cleanup: null };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function runAdd(positionals, values) {
|
|
234
|
+
if (positionals.length !== 1) throw new UsageError('用法:fde2e-bu skills add <src> [--skill <subdir>] [--dest agents|claude] [--force]', { exitCode: 2 });
|
|
235
|
+
const src = positionals[0];
|
|
236
|
+
const destName = values.dest ? String(values.dest) : 'agents';
|
|
237
|
+
if (!(destName in DEST_DIRS)) throw new UsageError(`--dest 取值 agents|claude(收到 ${destName})`);
|
|
238
|
+
|
|
239
|
+
const { dir: srcDir, cleanup } = resolveSource(src, values.skill ? String(values.skill) : undefined);
|
|
240
|
+
try {
|
|
241
|
+
if (!isDir(srcDir)) throw new Error(`源不是目录:${srcDir}`);
|
|
242
|
+
// 源校验关目录名一致性(源目录名可异于 name;安装以 name 建目录)
|
|
243
|
+
const check = validateSkillDir(srcDir, { dirNameCheck: false });
|
|
244
|
+
if (!check.pass) {
|
|
245
|
+
console.error(`源技能未过 validate,拒绝安装:${srcDir}`);
|
|
246
|
+
for (const f of check.failures) console.error(` - ${f}`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
const parsed = parseFrontmatter(readFileSync(join(srcDir, SKILL_FILE), 'utf8'));
|
|
250
|
+
const { name, version } = disclosedFields(parsed.data);
|
|
251
|
+
|
|
252
|
+
// 装载根:向上找既有技能根(保持与 list 同一工作区),找不到落在 cwd
|
|
253
|
+
const found = findSkillsWorkspace(process.cwd());
|
|
254
|
+
const workspace = found.workspace ?? process.cwd();
|
|
255
|
+
const target = join(workspace, DEST_DIRS[destName], name);
|
|
256
|
+
// r1 P1-2:源即目标(把已装目录当源再 --force)=自装,先删后拷会把自己删光——拒绝。
|
|
257
|
+
// realpath 归一比较(macOS /tmp→/private/tmp 符号链接下字符串比较会漏判)
|
|
258
|
+
const realOf = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
259
|
+
if (realOf(srcDir) === realOf(target)) {
|
|
260
|
+
console.error(`源即目标:${srcDir} → ${target}——拒绝自装(如需重装请以原始来源为源)`);
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
if (isDir(target) && !values.force) {
|
|
264
|
+
console.log(`已安装:${name}(${target})——同名已装不重复安装;--force 覆盖重装`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
installSkill(srcDir, target);
|
|
268
|
+
console.log(`已安装:${name}${version ? `@${version}` : ''} → ${target}(源:${src})`);
|
|
269
|
+
return 0;
|
|
270
|
+
} finally {
|
|
271
|
+
cleanup?.(); // r1 item 12:克隆临时目录必回收
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function runRemove(positionals, values) {
|
|
276
|
+
if (positionals.length !== 1) throw new UsageError('用法:fde2e-bu skills remove <name> [--dest agents|claude] [--all]', { exitCode: 2 });
|
|
277
|
+
const name = positionals[0];
|
|
278
|
+
// r1 P0-1:技能名先过 NAME_RE——`../..`、`..`、绝对路径、含分隔符一律 exit 1,
|
|
279
|
+
// 不得触碰任何目录(此前 join(base, dest, '../..') 会递归删掉工作区)
|
|
280
|
+
if (!NAME_RE.test(name)) throw new IllegalSkillNameError(name);
|
|
281
|
+
const { workspace, roots } = findSkillsWorkspace(process.cwd());
|
|
282
|
+
const destNames = values.all ? Object.keys(DEST_DIRS) : values.dest ? [String(values.dest)] : roots.map((r) => r.name);
|
|
283
|
+
const base = workspace ?? process.cwd();
|
|
284
|
+
let removed = 0;
|
|
285
|
+
for (const dn of new Set(destNames)) {
|
|
286
|
+
if (!(dn in DEST_DIRS)) throw new UsageError(`--dest 取值 agents|claude(收到 ${dn})`);
|
|
287
|
+
const destRoot = resolve(base, DEST_DIRS[dn]);
|
|
288
|
+
const target = join(destRoot, name);
|
|
289
|
+
// r1 P0-1 双保险:即使未来 NAME_RE 放宽,删除目标必须 containment 于技能根内
|
|
290
|
+
if (!isContained(destRoot, target)) throw new IllegalSkillNameError(name);
|
|
291
|
+
if (isDir(target)) {
|
|
292
|
+
rmSync(target, { recursive: true, force: true });
|
|
293
|
+
console.log(`已移除:${target}`);
|
|
294
|
+
removed++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (removed === 0) console.log(`未安装:${name}(无 .agents/skills/.claude/skills 下的同名目录)——幂等跳过`);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------- 组入口 ----------
|
|
302
|
+
|
|
303
|
+
/** @returns {number} exit code */
|
|
304
|
+
export function cmdSkills(rest) {
|
|
305
|
+
const [sub, ...subRest] = rest;
|
|
306
|
+
if (!(sub in SUB_OPTIONS)) {
|
|
307
|
+
throw new UsageError(`未知子命令:skills ${sub ?? ''}(可用:list/add/remove/validate)`, { exitCode: 2 });
|
|
308
|
+
}
|
|
309
|
+
assertFlags(subRest, SUB_FLAGS[sub], `skills ${sub}`);
|
|
310
|
+
const { positionals, values } = parse(subRest, { options: SUB_OPTIONS[sub] });
|
|
311
|
+
if (values.help) return 0; // help 文本由 cli.mjs 统一打印(组级 --help 已拦截;此处兜底静默 0)
|
|
312
|
+
switch (sub) {
|
|
313
|
+
case 'list': {
|
|
314
|
+
assertNoPositionals(positionals, 'skills list');
|
|
315
|
+
return runList(values);
|
|
316
|
+
}
|
|
317
|
+
case 'add': return runAdd(positionals, values);
|
|
318
|
+
case 'remove': return runRemove(positionals, values);
|
|
319
|
+
case 'validate': return runValidate(positionals, values);
|
|
320
|
+
default:
|
|
321
|
+
throw new UsageError(`未知子命令:skills ${sub ?? ''}(可用:list/add/remove/validate)`, { exitCode: 2 });
|
|
322
|
+
}
|
|
323
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = {};
|