@becrafter/prompt-manager 0.2.2 → 0.2.3-alpha.7
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/package.json +13 -4
- package/packages/server/api/admin.routes.js +385 -0
- package/packages/server/mcp/prompt.handler.js +6 -6
- package/packages/server/server.js +13 -0
- package/packages/server/services/TerminalService.js +37 -17
- package/packages/server/services/skill-sync.service.js +223 -0
- package/packages/server/services/skills.service.js +731 -0
- package/packages/server/utils/config.js +8 -0
- package/packages/server/utils/util.js +27 -21
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
import { util } from '../utils/util.js';
|
|
8
|
+
import { config } from '../utils/config.js';
|
|
9
|
+
|
|
10
|
+
// 技能限制常量
|
|
11
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 单个文件最大 10MB
|
|
12
|
+
const MAX_FILES_COUNT = 50; // 每个技能最多 50 个文件
|
|
13
|
+
const MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 技能总大小最大 100MB
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Skill元数据验证Schema(严格遵循官方文档)
|
|
17
|
+
* 参考: https://code.claude.com/docs/zh-CN/skills#available-metadata-fields
|
|
18
|
+
*/
|
|
19
|
+
const SkillFrontmatterSchema = z.object({
|
|
20
|
+
name: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1, '技能名称不能为空')
|
|
23
|
+
.max(64, '技能名称不能超过64个字符')
|
|
24
|
+
.regex(/^[a-z0-9-\u4e00-\u9fa5]+$/i, '技能名称只能包含字母、数字、连字符和中文'),
|
|
25
|
+
|
|
26
|
+
description: z.string().min(1, '技能描述不能为空').max(1024, '技能描述不能超过1024个字符'),
|
|
27
|
+
|
|
28
|
+
version: z.string().optional().default('0.0.1'),
|
|
29
|
+
|
|
30
|
+
allowedTools: z.array(z.string()).optional(),
|
|
31
|
+
|
|
32
|
+
model: z.string().optional(),
|
|
33
|
+
|
|
34
|
+
context: z.enum(['fork', 'shared']).optional(),
|
|
35
|
+
|
|
36
|
+
agent: z.string().optional(),
|
|
37
|
+
|
|
38
|
+
userInvocable: z.boolean().optional().default(true),
|
|
39
|
+
|
|
40
|
+
disableModelInvocation: z.boolean().optional().default(false),
|
|
41
|
+
|
|
42
|
+
hooks: z
|
|
43
|
+
.object({
|
|
44
|
+
PreToolUse: z
|
|
45
|
+
.array(
|
|
46
|
+
z.object({
|
|
47
|
+
matcher: z.string(),
|
|
48
|
+
hooks: z.array(
|
|
49
|
+
z.object({
|
|
50
|
+
type: z.string(),
|
|
51
|
+
command: z.string(),
|
|
52
|
+
once: z.boolean().optional().default(false)
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
.optional(),
|
|
58
|
+
PostToolUse: z
|
|
59
|
+
.array(
|
|
60
|
+
z.object({
|
|
61
|
+
matcher: z.string(),
|
|
62
|
+
hooks: z.array(
|
|
63
|
+
z.object({
|
|
64
|
+
type: z.string(),
|
|
65
|
+
command: z.string(),
|
|
66
|
+
once: z.boolean().optional().default(false)
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
)
|
|
71
|
+
.optional(),
|
|
72
|
+
Stop: z
|
|
73
|
+
.array(
|
|
74
|
+
z.object({
|
|
75
|
+
matcher: z.string(),
|
|
76
|
+
hooks: z.array(
|
|
77
|
+
z.object({
|
|
78
|
+
type: z.string(),
|
|
79
|
+
command: z.string()
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
.optional()
|
|
85
|
+
})
|
|
86
|
+
.optional()
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 技能加载项(带元数据)
|
|
91
|
+
* 注意:此 Schema 当前保留用于类型定义和文档目的
|
|
92
|
+
*/
|
|
93
|
+
/* eslint-disable no-unused-vars */
|
|
94
|
+
const SkillItemSchema = z.object({
|
|
95
|
+
id: z.string(),
|
|
96
|
+
name: z.string(),
|
|
97
|
+
description: z.string(),
|
|
98
|
+
allowedTools: z.array(z.string()).optional(),
|
|
99
|
+
model: z.string().optional(),
|
|
100
|
+
context: z.enum(['fork', 'shared']).optional(),
|
|
101
|
+
agent: z.string().optional(),
|
|
102
|
+
userInvocable: z.boolean().optional(),
|
|
103
|
+
disableModelInvocation: z.boolean().optional(),
|
|
104
|
+
hooks: z.any().optional(),
|
|
105
|
+
type: z.enum(['built-in', 'custom']),
|
|
106
|
+
filePath: z.string(),
|
|
107
|
+
skillDir: z.string(),
|
|
108
|
+
relativePath: z.string(),
|
|
109
|
+
yamlContent: z.string(),
|
|
110
|
+
markdownContent: z.string(),
|
|
111
|
+
updatedAt: z.string().optional()
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 技能管理器类
|
|
116
|
+
* 严格遵循 model.service.js 的设计模式
|
|
117
|
+
*/
|
|
118
|
+
class SkillsManager {
|
|
119
|
+
constructor() {
|
|
120
|
+
this.builtInDir = path.join(util.getBuiltInConfigsDir(), 'skills/built-in');
|
|
121
|
+
this.customDir = config.getSkillsDir();
|
|
122
|
+
this.loadedSkills = new Map();
|
|
123
|
+
this.idToPathMap = new Map();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 生成技能唯一ID(基于目录相对路径)
|
|
128
|
+
* 遵循 model.service.js 的 generateUniqueId 模式
|
|
129
|
+
*/
|
|
130
|
+
generateUniqueId(relativePath) {
|
|
131
|
+
const hash = crypto.createHash('sha256');
|
|
132
|
+
hash.update(relativePath);
|
|
133
|
+
return hash.digest('hex').substring(0, 8);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 确保目录存在
|
|
138
|
+
*/
|
|
139
|
+
async ensureDirectories() {
|
|
140
|
+
await fs.ensureDir(this.builtInDir);
|
|
141
|
+
await fs.ensureDir(this.customDir);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 加载所有技能
|
|
146
|
+
* 遵循 model.service.js loadModels 模式
|
|
147
|
+
*/
|
|
148
|
+
async loadSkills() {
|
|
149
|
+
try {
|
|
150
|
+
logger.info('开始加载技能配置');
|
|
151
|
+
|
|
152
|
+
await this.ensureDirectories();
|
|
153
|
+
|
|
154
|
+
this.loadedSkills.clear();
|
|
155
|
+
this.idToPathMap.clear();
|
|
156
|
+
|
|
157
|
+
// 加载内置技能
|
|
158
|
+
await this.loadSkillsFromDir(this.builtInDir, true);
|
|
159
|
+
|
|
160
|
+
// 加载自定义技能
|
|
161
|
+
await this.loadSkillsFromDir(this.customDir, false);
|
|
162
|
+
|
|
163
|
+
logger.info(`技能加载完成: 共 ${this.loadedSkills.size} 个技能`);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
success: this.loadedSkills.size,
|
|
167
|
+
skills: Array.from(this.loadedSkills.values())
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.error('加载技能时发生错误:', error);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 从目录加载技能
|
|
177
|
+
* 遵循 model.service.js loadModelsFromDir 模式
|
|
178
|
+
*/
|
|
179
|
+
async loadSkillsFromDir(dir, isBuiltIn) {
|
|
180
|
+
try {
|
|
181
|
+
if (!fs.existsSync(dir)) {
|
|
182
|
+
logger.warn(`技能目录不存在: ${dir}`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
187
|
+
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
if (!entry.isDirectory()) continue;
|
|
190
|
+
if (entry.name.startsWith('.')) continue;
|
|
191
|
+
|
|
192
|
+
const skillDir = path.join(dir, entry.name);
|
|
193
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
194
|
+
|
|
195
|
+
// 必须存在 SKILL.md 文件
|
|
196
|
+
if (!fs.existsSync(skillFile)) {
|
|
197
|
+
logger.warn(`技能目录缺少 SKILL.md: ${skillDir}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const content = await fs.readFile(skillFile, 'utf8');
|
|
203
|
+
const skill = await this.parseSkillFile(skillDir, entry.name, content, isBuiltIn);
|
|
204
|
+
|
|
205
|
+
if (skill) {
|
|
206
|
+
this.loadedSkills.set(skill.id, skill);
|
|
207
|
+
this.idToPathMap.set(skill.id, skill.relativePath);
|
|
208
|
+
logger.debug(`加载技能: ${skill.name} -> ID: ${skill.id} (${isBuiltIn ? '内置' : '自定义'})`);
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logger.error(`加载技能 ${entry.name} 失败:`, error.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logger.error(`扫描技能目录 ${dir} 时发生错误:`, error.message);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 解析SKILL.md文件
|
|
221
|
+
* 严格遵循官方格式:YAML前置 + Markdown
|
|
222
|
+
*/
|
|
223
|
+
async parseSkillFile(skillDir, dirName, content, isBuiltIn) {
|
|
224
|
+
// 分离YAML前置和Markdown内容
|
|
225
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
226
|
+
|
|
227
|
+
if (!frontmatterMatch) {
|
|
228
|
+
throw new Error('SKILL.md 必须包含 YAML 前置部分(以 --- 包裹)');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const frontmatterYaml = frontmatterMatch[1];
|
|
232
|
+
const markdownContent = frontmatterMatch[2].trim();
|
|
233
|
+
|
|
234
|
+
// 解析YAML前置
|
|
235
|
+
let frontmatter;
|
|
236
|
+
try {
|
|
237
|
+
frontmatter = yaml.load(frontmatterYaml);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw new Error(`YAML 前置解析失败: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 验证前置字段
|
|
243
|
+
const validatedFrontmatter = SkillFrontmatterSchema.parse(frontmatter);
|
|
244
|
+
|
|
245
|
+
// 生成唯一ID
|
|
246
|
+
const relativePath = path.join(dirName, 'SKILL.md');
|
|
247
|
+
const uniqueId = this.generateUniqueId(relativePath);
|
|
248
|
+
|
|
249
|
+
// 递归获取所有文件
|
|
250
|
+
const files = [];
|
|
251
|
+
let totalSize = 0;
|
|
252
|
+
|
|
253
|
+
const readDirRecursive = async (currentDir, relativeToSkillDir = '') => {
|
|
254
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
if (entry.name.startsWith('.')) continue;
|
|
257
|
+
|
|
258
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
259
|
+
const relativePath = path.join(relativeToSkillDir, entry.name);
|
|
260
|
+
|
|
261
|
+
if (entry.isDirectory()) {
|
|
262
|
+
await readDirRecursive(fullPath, relativePath);
|
|
263
|
+
} else if (entry.isFile()) {
|
|
264
|
+
const stats = await fs.stat(fullPath);
|
|
265
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
266
|
+
logger.warn(`跳过超大文件 (${(stats.size / 1024).toFixed(2)}KB): ${fullPath}`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (totalSize + stats.size > MAX_TOTAL_SIZE) {
|
|
271
|
+
logger.warn(`技能总大小超过限制,停止读取后续文件: ${skillDir}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const fileContent = await fs.readFile(fullPath, 'utf8');
|
|
276
|
+
files.push({
|
|
277
|
+
name: relativePath,
|
|
278
|
+
content: fileContent
|
|
279
|
+
});
|
|
280
|
+
totalSize += stats.size;
|
|
281
|
+
|
|
282
|
+
if (files.length >= MAX_FILES_COUNT) return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await readDirRecursive(skillDir);
|
|
289
|
+
if (files.length > MAX_FILES_COUNT) {
|
|
290
|
+
logger.warn(`技能目录文件数量超过限制 (${files.length} > ${MAX_FILES_COUNT}): ${skillDir}`);
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
logger.error(`读取技能目录文件失败 ${skillDir}:`, error.message);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 获取文件修改时间
|
|
297
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
298
|
+
let updatedAt = '';
|
|
299
|
+
try {
|
|
300
|
+
const stats = await fs.stat(skillFile);
|
|
301
|
+
updatedAt = stats.mtime.toISOString();
|
|
302
|
+
} catch (error) {
|
|
303
|
+
updatedAt = new Date().toISOString();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
id: uniqueId,
|
|
308
|
+
name: validatedFrontmatter.name,
|
|
309
|
+
description: validatedFrontmatter.description,
|
|
310
|
+
version: validatedFrontmatter.version,
|
|
311
|
+
allowedTools: validatedFrontmatter.allowedTools,
|
|
312
|
+
model: validatedFrontmatter.model,
|
|
313
|
+
context: validatedFrontmatter.context,
|
|
314
|
+
agent: validatedFrontmatter.agent,
|
|
315
|
+
userInvocable: validatedFrontmatter.userInvocable,
|
|
316
|
+
disableModelInvocation: validatedFrontmatter.disableModelInvocation,
|
|
317
|
+
hooks: validatedFrontmatter.hooks,
|
|
318
|
+
type: isBuiltIn ? 'built-in' : 'custom',
|
|
319
|
+
filePath: path.join(skillDir, 'SKILL.md'),
|
|
320
|
+
skillDir,
|
|
321
|
+
relativePath,
|
|
322
|
+
yamlContent: frontmatterYaml,
|
|
323
|
+
markdownContent,
|
|
324
|
+
fullContent: content,
|
|
325
|
+
files,
|
|
326
|
+
updatedAt
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 获取所有已加载的技能
|
|
332
|
+
*/
|
|
333
|
+
getSkills() {
|
|
334
|
+
return Array.from(this.loadedSkills.values());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 获取技能列表概要(不包含文件内容,用于列表展示)
|
|
339
|
+
*/
|
|
340
|
+
getSkillsSummary() {
|
|
341
|
+
return Array.from(this.loadedSkills.values()).map(skill => ({
|
|
342
|
+
id: skill.id,
|
|
343
|
+
name: skill.name,
|
|
344
|
+
description: skill.description,
|
|
345
|
+
version: skill.version,
|
|
346
|
+
updatedAt: skill.updatedAt,
|
|
347
|
+
type: skill.type,
|
|
348
|
+
fileCount: skill.files ? skill.files.length : 1
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 根据ID获取技能
|
|
354
|
+
*/
|
|
355
|
+
getSkill(id) {
|
|
356
|
+
return this.loadedSkills.get(id) || null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 根据路径获取技能
|
|
361
|
+
*/
|
|
362
|
+
getSkillByPath(relativePath) {
|
|
363
|
+
const id = this.idToPathMap.get(relativePath);
|
|
364
|
+
return id ? this.loadedSkills.get(id) : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 创建新技能
|
|
369
|
+
*/
|
|
370
|
+
async createSkill(skillData) {
|
|
371
|
+
try {
|
|
372
|
+
// 验证技能数据(验证失败会抛出异常)
|
|
373
|
+
SkillFrontmatterSchema.parse(skillData.frontmatter);
|
|
374
|
+
|
|
375
|
+
// 验证目录名称(支持中文,保留大小写)
|
|
376
|
+
const dirName = skillData.name.replace(/[\\/:*?"<>|]/g, '-');
|
|
377
|
+
const skillDir = path.join(this.customDir, dirName);
|
|
378
|
+
|
|
379
|
+
// 检查目录是否已存在
|
|
380
|
+
if (fs.existsSync(skillDir)) {
|
|
381
|
+
throw new Error(`技能 "${skillData.name}" 已存在(文件名冲突)`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 构建SKILL.md内容
|
|
385
|
+
const yamlContent = yaml.dump(skillData.frontmatter, { indent: 2 });
|
|
386
|
+
const fullContent = `---\n${yamlContent}---\n\n${skillData.markdown || ''}`;
|
|
387
|
+
|
|
388
|
+
// 创建目录和文件
|
|
389
|
+
await fs.ensureDir(skillDir);
|
|
390
|
+
await fs.writeFile(path.join(skillDir, 'SKILL.md'), fullContent, 'utf8');
|
|
391
|
+
|
|
392
|
+
// 写入其他文件
|
|
393
|
+
if (skillData.files && Array.isArray(skillData.files)) {
|
|
394
|
+
if (skillData.files.length > MAX_FILES_COUNT) {
|
|
395
|
+
throw new Error(`文件数量超过限制 (最多 ${MAX_FILES_COUNT} 个)`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let currentTotalSize = 0;
|
|
399
|
+
for (const file of skillData.files) {
|
|
400
|
+
if (file.name === 'SKILL.md') continue;
|
|
401
|
+
|
|
402
|
+
const contentSize = Buffer.byteLength(file.content, 'utf8');
|
|
403
|
+
if (contentSize > MAX_FILE_SIZE) {
|
|
404
|
+
throw new Error(`文件 "${file.name}" 大小超过限制 (最大 1MB)`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
currentTotalSize += contentSize;
|
|
408
|
+
if (currentTotalSize > MAX_TOTAL_SIZE) {
|
|
409
|
+
throw new Error(`技能总大小超过限制 (最大 10MB)`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 确保文件名安全并支持子目录
|
|
413
|
+
const filePath = path.join(skillDir, file.name);
|
|
414
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
415
|
+
await fs.writeFile(filePath, file.content, 'utf8');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 解析并加载技能
|
|
420
|
+
const skill = await this.parseSkillFile(skillDir, dirName, fullContent, false);
|
|
421
|
+
|
|
422
|
+
if (skill) {
|
|
423
|
+
this.loadedSkills.set(skill.id, skill);
|
|
424
|
+
this.idToPathMap.set(skill.id, skill.relativePath);
|
|
425
|
+
logger.info(`创建技能: ${skill.name} -> ID: ${skill.id}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return skill;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
logger.error('创建技能失败:', error);
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 更新技能
|
|
437
|
+
*/
|
|
438
|
+
async updateSkill(id, skillData) {
|
|
439
|
+
try {
|
|
440
|
+
const existingSkill = this.getSkill(id);
|
|
441
|
+
|
|
442
|
+
if (!existingSkill) {
|
|
443
|
+
throw new Error(`技能不存在: ${id}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (existingSkill.type === 'built-in') {
|
|
447
|
+
throw new Error('内置技能不能修改');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 验证技能数据(验证失败会抛出异常)
|
|
451
|
+
if (skillData.frontmatter) {
|
|
452
|
+
SkillFrontmatterSchema.parse(skillData.frontmatter);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 检查是否需要重命名目录(如果名称改变了)
|
|
456
|
+
let skillDir = existingSkill.skillDir;
|
|
457
|
+
let dirName = path.basename(skillDir);
|
|
458
|
+
const newName = skillData.frontmatter?.name;
|
|
459
|
+
|
|
460
|
+
if (newName && newName !== existingSkill.name) {
|
|
461
|
+
const newDirName = newName.replace(/[\\/:*?"<>|]/g, '-');
|
|
462
|
+
const newSkillDir = path.join(path.dirname(skillDir), newDirName);
|
|
463
|
+
|
|
464
|
+
if (newSkillDir !== skillDir) {
|
|
465
|
+
// 检查新目录是否已存在
|
|
466
|
+
if (fs.existsSync(newSkillDir)) {
|
|
467
|
+
throw new Error(`无法重命名:技能目录 "${newDirName}" 已存在`);
|
|
468
|
+
}
|
|
469
|
+
// 执行重命名
|
|
470
|
+
await fs.rename(skillDir, newSkillDir);
|
|
471
|
+
skillDir = newSkillDir;
|
|
472
|
+
dirName = newDirName;
|
|
473
|
+
logger.info(`技能目录已重命名: ${existingSkill.skillDir} -> ${skillDir}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 构建SKILL.md内容
|
|
478
|
+
let fullContent;
|
|
479
|
+
if (skillData.frontmatter) {
|
|
480
|
+
const yamlContent = yaml.dump(skillData.frontmatter, { indent: 2 });
|
|
481
|
+
fullContent = `---\n${yamlContent}---\n\n${skillData.markdown || existingSkill.markdownContent}`;
|
|
482
|
+
} else {
|
|
483
|
+
// 如果没有提供 frontmatter,则保持原样,只更新 markdown 部分(如果提供)
|
|
484
|
+
const yamlContent = existingSkill.yamlContent;
|
|
485
|
+
fullContent = `---\n${yamlContent}---\n\n${skillData.markdown || existingSkill.markdownContent}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 更新 SKILL.md 文件
|
|
489
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
490
|
+
await fs.writeFile(skillFile, fullContent, 'utf8');
|
|
491
|
+
|
|
492
|
+
// 更新其他文件
|
|
493
|
+
if (skillData.files && Array.isArray(skillData.files)) {
|
|
494
|
+
if (skillData.files.length > MAX_FILES_COUNT) {
|
|
495
|
+
throw new Error(`文件数量超过限制 (最多 ${MAX_FILES_COUNT} 个)`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 递归获取当前所有文件
|
|
499
|
+
const getAllFilesRecursive = async (currentDir, relativeToSkillDir = '') => {
|
|
500
|
+
let results = [];
|
|
501
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
const relPath = path.join(relativeToSkillDir, entry.name);
|
|
504
|
+
if (entry.isDirectory()) {
|
|
505
|
+
results = results.concat(await getAllFilesRecursive(path.join(currentDir, entry.name), relPath));
|
|
506
|
+
} else {
|
|
507
|
+
results.push(relPath);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return results;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const currentFiles = await getAllFilesRecursive(skillDir);
|
|
514
|
+
const newFileNames = skillData.files.map(f => f.name);
|
|
515
|
+
|
|
516
|
+
// 写入/更新文件
|
|
517
|
+
let currentTotalSize = 0;
|
|
518
|
+
for (const file of skillData.files) {
|
|
519
|
+
if (file.name === 'SKILL.md') continue;
|
|
520
|
+
|
|
521
|
+
const contentSize = Buffer.byteLength(file.content, 'utf8');
|
|
522
|
+
if (contentSize > MAX_FILE_SIZE) {
|
|
523
|
+
throw new Error(`文件 "${file.name}" 大小超过限制 (最大 1MB)`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
currentTotalSize += contentSize;
|
|
527
|
+
if (currentTotalSize > MAX_TOTAL_SIZE) {
|
|
528
|
+
throw new Error(`技能总大小超过限制 (最大 10MB)`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const filePath = path.join(skillDir, file.name);
|
|
532
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
533
|
+
await fs.writeFile(filePath, file.content, 'utf8');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 删除已移除的文件
|
|
537
|
+
for (const fileName of currentFiles) {
|
|
538
|
+
if (fileName === 'SKILL.md' || fileName.startsWith('.')) continue;
|
|
539
|
+
if (!newFileNames.includes(fileName)) {
|
|
540
|
+
const filePath = path.join(skillDir, fileName);
|
|
541
|
+
await fs.remove(filePath);
|
|
542
|
+
|
|
543
|
+
// 尝试删除空的父目录
|
|
544
|
+
let parentDir = path.dirname(filePath);
|
|
545
|
+
while (parentDir !== skillDir) {
|
|
546
|
+
const files = await fs.readdir(parentDir);
|
|
547
|
+
if (files.length === 0) {
|
|
548
|
+
await fs.remove(parentDir);
|
|
549
|
+
parentDir = path.dirname(parentDir);
|
|
550
|
+
} else {
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 重新解析
|
|
559
|
+
const newSkill = await this.parseSkillFile(skillDir, dirName, fullContent, false);
|
|
560
|
+
|
|
561
|
+
if (newSkill) {
|
|
562
|
+
// 如果 ID 发生了变化(因为目录名变了),需要清理旧的 ID
|
|
563
|
+
if (newSkill.id !== id) {
|
|
564
|
+
this.loadedSkills.delete(id);
|
|
565
|
+
this.idToPathMap.delete(id);
|
|
566
|
+
}
|
|
567
|
+
this.loadedSkills.set(newSkill.id, newSkill);
|
|
568
|
+
this.idToPathMap.set(newSkill.id, newSkill.relativePath);
|
|
569
|
+
logger.info(`更新技能: ${newSkill.name} -> ID: ${newSkill.id} (原 ID: ${id})`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return newSkill;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
logger.error('更新技能失败:', error);
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 删除技能
|
|
581
|
+
*/
|
|
582
|
+
async deleteSkill(id) {
|
|
583
|
+
try {
|
|
584
|
+
const skill = this.getSkill(id);
|
|
585
|
+
|
|
586
|
+
if (!skill) {
|
|
587
|
+
throw new Error(`技能不存在: ${id}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (skill.type === 'built-in') {
|
|
591
|
+
throw new Error('内置技能不能删除');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 删除整个技能目录
|
|
595
|
+
await fs.remove(skill.skillDir);
|
|
596
|
+
|
|
597
|
+
// 从内存中移除
|
|
598
|
+
this.loadedSkills.delete(id);
|
|
599
|
+
this.idToPathMap.delete(id);
|
|
600
|
+
|
|
601
|
+
logger.info(`删除技能: ${skill.name} -> ID: ${id}`);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
logger.error('删除技能失败:', error);
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 导出技能为 ZIP Buffer
|
|
610
|
+
*/
|
|
611
|
+
async exportSkill(id) {
|
|
612
|
+
try {
|
|
613
|
+
const skill = this.getSkill(id);
|
|
614
|
+
if (!skill) {
|
|
615
|
+
throw new Error(`技能不存在: ${id}`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const AdmZip = (await import('adm-zip')).default;
|
|
619
|
+
const zip = new AdmZip();
|
|
620
|
+
|
|
621
|
+
// 将技能目录下的所有文件添加到 ZIP,放在以技能名命名的根目录下
|
|
622
|
+
const skillDir = skill.skillDir;
|
|
623
|
+
const skillName = skill.name;
|
|
624
|
+
|
|
625
|
+
// 递归添加文件
|
|
626
|
+
const addFilesToZip = async (currentDir, zipPathPrefix) => {
|
|
627
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
628
|
+
for (const entry of entries) {
|
|
629
|
+
if (entry.name.startsWith('.')) continue;
|
|
630
|
+
|
|
631
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
632
|
+
const zipPath = path.join(zipPathPrefix, entry.name);
|
|
633
|
+
|
|
634
|
+
if (entry.isDirectory()) {
|
|
635
|
+
await addFilesToZip(fullPath, zipPath);
|
|
636
|
+
} else if (entry.isFile()) {
|
|
637
|
+
const content = await fs.readFile(fullPath);
|
|
638
|
+
zip.addFile(zipPath, content);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
await addFilesToZip(skillDir, skillName);
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
buffer: zip.toBuffer(),
|
|
647
|
+
fileName: `${skillName}.zip`
|
|
648
|
+
};
|
|
649
|
+
} catch (error) {
|
|
650
|
+
logger.error(`导出技能 ${id} 失败:`, error);
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* 重新加载技能
|
|
657
|
+
*/
|
|
658
|
+
async reloadSkills() {
|
|
659
|
+
logger.info('重新加载技能');
|
|
660
|
+
return await this.loadSkills();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 验证技能数据结构
|
|
665
|
+
*/
|
|
666
|
+
validateSkillFrontmatter(frontmatter) {
|
|
667
|
+
return SkillFrontmatterSchema.parse(frontmatter);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* 解析SKILL.md内容(供API使用)
|
|
672
|
+
*/
|
|
673
|
+
parseSkillContent(content) {
|
|
674
|
+
return this.parseSkillFile('', 'temp', content, false);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 复制技能(用于复制功能)
|
|
679
|
+
*/
|
|
680
|
+
async duplicateSkill(id, newName) {
|
|
681
|
+
try {
|
|
682
|
+
const skill = this.getSkill(id);
|
|
683
|
+
|
|
684
|
+
if (!skill) {
|
|
685
|
+
throw new Error(`技能不存在: ${id}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 验证新名称(支持中文,保留大小写)
|
|
689
|
+
const newDirName = newName.replace(/[\\/:*?"<>|]/g, '-');
|
|
690
|
+
const newSkillDir = path.join(this.customDir, newDirName);
|
|
691
|
+
|
|
692
|
+
if (fs.existsSync(newSkillDir)) {
|
|
693
|
+
throw new Error(`技能 "${newName}" 已存在`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 复制目录
|
|
697
|
+
await fs.copy(skill.skillDir, newSkillDir);
|
|
698
|
+
|
|
699
|
+
// 读取并更新SKILL.md中的名称
|
|
700
|
+
const newSkillFile = path.join(newSkillDir, 'SKILL.md');
|
|
701
|
+
let newContent = await fs.readFile(newSkillFile, 'utf8');
|
|
702
|
+
|
|
703
|
+
// 更新YAML前置中的名称
|
|
704
|
+
const frontmatterMatch = newContent.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
705
|
+
if (frontmatterMatch) {
|
|
706
|
+
const frontmatter = yaml.load(frontmatterMatch[1]);
|
|
707
|
+
frontmatter.name = newName;
|
|
708
|
+
frontmatter.description = frontmatter.description || `${skill.description} (副本)`;
|
|
709
|
+
const newYamlContent = yaml.dump(frontmatter, { indent: 2 });
|
|
710
|
+
newContent = `---\n${newYamlContent}---\n${frontmatterMatch[2]}`;
|
|
711
|
+
await fs.writeFile(newSkillFile, newContent, 'utf8');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// 重新加载技能
|
|
715
|
+
await this.reloadSkills();
|
|
716
|
+
|
|
717
|
+
// 返回新技能
|
|
718
|
+
const newSkill = this.getSkillByPath(path.join(newDirName, 'SKILL.md'));
|
|
719
|
+
return newSkill;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
logger.error('复制技能失败:', error);
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 创建全局SkillsManager实例
|
|
728
|
+
export const skillsManager = new SkillsManager();
|
|
729
|
+
|
|
730
|
+
// 导出SkillsManager类供测试使用
|
|
731
|
+
export { SkillsManager };
|