skill-market-cli 1.1.3 → 1.2.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/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -126,6 +126,32 @@ class ApiClient {
|
|
|
126
126
|
return response.data;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// SkillTag API
|
|
130
|
+
async searchSkillTags(keyword) {
|
|
131
|
+
const response = await this.client.get('/skill/tags/search', {
|
|
132
|
+
params: { keyword }
|
|
133
|
+
});
|
|
134
|
+
return response.data;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async createSkillTag(nameZh, nameEn) {
|
|
138
|
+
const response = await this.client.post('/skill/tags/create', {
|
|
139
|
+
nameZh,
|
|
140
|
+
nameEn
|
|
141
|
+
});
|
|
142
|
+
return response.data;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getAllSkillTags() {
|
|
146
|
+
const response = await this.client.get('/skill/tags');
|
|
147
|
+
return response.data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async resolveSkillTags(tags) {
|
|
151
|
+
const response = await this.client.post('/skill/tags/resolve', { tags });
|
|
152
|
+
return response.data;
|
|
153
|
+
}
|
|
154
|
+
|
|
129
155
|
// OAuth API
|
|
130
156
|
async getUserInfo() {
|
|
131
157
|
const serverConfig = getServerConfig();
|
package/src/commands/update.js
CHANGED
|
@@ -88,7 +88,7 @@ async function update(skillId, options) {
|
|
|
88
88
|
? tags.map((t) => String(t).trim()).filter(Boolean)
|
|
89
89
|
: ['general'];
|
|
90
90
|
const modelFinal =
|
|
91
|
-
model && String(model).trim() ? String(model).trim() : '
|
|
91
|
+
model && String(model).trim() ? String(model).trim() : '';
|
|
92
92
|
const rootUrlFinal =
|
|
93
93
|
existingSkill.rootUrl && String(existingSkill.rootUrl).trim()
|
|
94
94
|
? String(existingSkill.rootUrl).trim()
|
package/src/commands/upload.js
CHANGED
|
@@ -11,6 +11,84 @@ const {
|
|
|
11
11
|
promptOnlyExamples
|
|
12
12
|
} = require('../lib/skill-upload-helpers');
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* normalizeTagsForUpload 规范化用户/AI 提交的原始标签:
|
|
16
|
+
* 1. 拆分连字符复合标签(如 "email-ses-smtp" → ["SES", "SMTP"]),滤除过于泛化的片段
|
|
17
|
+
* 2. 拆分中文复合标签(按常见分隔符)
|
|
18
|
+
* 3. 去重 + 去空
|
|
19
|
+
*
|
|
20
|
+
* 约束原则(抽象级别,不绑定具体案例):
|
|
21
|
+
* - 原子性:每个 tag 只表示一个明确的技术/平台/概念,不组合多个语义单元
|
|
22
|
+
* - 抽象层级:优先使用技术名词本身而非其应用场景;能用协议/标准名就不用产品名
|
|
23
|
+
* - 避免泛化词:过于宽泛的片段(如 "email", "service", "kit", "tool")不作为独立 tag,
|
|
24
|
+
* 除非它本身就是该 Skill 的核心主题且无更具体的替代词
|
|
25
|
+
*/
|
|
26
|
+
function normalizeTagsForUpload(tags) {
|
|
27
|
+
// 过于泛化的词(与具体技术无关或涵盖面过广),复合 tag 拆分后自动滤除
|
|
28
|
+
const genericWords = new Set([
|
|
29
|
+
'email', 'service', 'kit', 'tool', 'api', 'app', 'lib', 'utils',
|
|
30
|
+
'helper', 'core', 'base', 'common', 'demo', 'example', 'test',
|
|
31
|
+
'simple', 'basic', 'advanced', 'pro', 'lite', 'plus'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const result = [];
|
|
35
|
+
for (const raw of tags) {
|
|
36
|
+
const trimmed = String(raw).trim();
|
|
37
|
+
if (!trimmed) continue;
|
|
38
|
+
|
|
39
|
+
// 1. 拆分连字符复合词
|
|
40
|
+
if (trimmed.includes('-') && !trimmed.startsWith('file-') && !trimmed.startsWith('os-')) {
|
|
41
|
+
const parts = trimmed.split('-').map(p => p.trim()).filter(Boolean);
|
|
42
|
+
const meaningful = parts.filter(p => !genericWords.has(p.toLowerCase()));
|
|
43
|
+
if (meaningful.length > 0) {
|
|
44
|
+
for (const p of meaningful) result.push(p);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. 拆分中文复合词(含空格)
|
|
50
|
+
if (/[一-鿿]/.test(trimmed) && /\s/.test(trimmed)) {
|
|
51
|
+
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
52
|
+
for (const p of parts) result.push(p);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. 尝试检测中文连写复合词(无空格、多概念粘连)
|
|
57
|
+
// 常见技术后缀可作为拆分边界:云、服务、平台、协议、框架、工具、系统
|
|
58
|
+
const cnSplitHints = ['云', '服务', '平台', '协议', '框架', '工具', '系统', '邮件', '推送', '验证'];
|
|
59
|
+
let didSplit = false;
|
|
60
|
+
for (const hint of cnSplitHints) {
|
|
61
|
+
const idx = trimmed.indexOf(hint);
|
|
62
|
+
if (idx > 0 && idx + hint.length < trimmed.length) {
|
|
63
|
+
// hint 在中间,拆分
|
|
64
|
+
const before = trimmed.substring(0, idx + hint.length);
|
|
65
|
+
const after = trimmed.substring(idx + hint.length);
|
|
66
|
+
result.push(before, after);
|
|
67
|
+
didSplit = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (didSplit) continue;
|
|
72
|
+
|
|
73
|
+
result.push(trimmed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 常见缩写/协议名规范化:纯字母 2-4 字符全大写
|
|
77
|
+
const normalized = result.map(t => {
|
|
78
|
+
if (/^[a-zA-Z]{2,4}$/.test(t)) return t.toUpperCase();
|
|
79
|
+
return t;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 去重(大小写不敏感)
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
return normalized.filter(t => {
|
|
85
|
+
const key = t.toLowerCase();
|
|
86
|
+
if (seen.has(key)) return false;
|
|
87
|
+
seen.add(key);
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
14
92
|
/**
|
|
15
93
|
* 交互补全:名称、描述、标签、模型、rootUrl、用户案例 + 可选运行采集轨迹
|
|
16
94
|
*/
|
|
@@ -95,11 +173,15 @@ async function upload(skillPath, options = {}) {
|
|
|
95
173
|
{
|
|
96
174
|
type: 'input',
|
|
97
175
|
name: 'm',
|
|
98
|
-
message: '
|
|
99
|
-
|
|
176
|
+
message: '推荐模型(必填,如 claude-sonnet-4-6 / gpt-4o / deepseek-chat,请填写实际使用的模型):',
|
|
177
|
+
validate: (input) => (input && String(input).trim() ? true : '模型名不能为空,请填写你当前使用的模型名称')
|
|
100
178
|
}
|
|
101
179
|
]);
|
|
102
|
-
model = m || '
|
|
180
|
+
model = String(m || '').trim();
|
|
181
|
+
if (!model) {
|
|
182
|
+
console.error(chalk.red('模型名不能为空,上传已取消。'));
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
103
185
|
}
|
|
104
186
|
|
|
105
187
|
const modelFinal = String(model).trim();
|
|
@@ -186,12 +268,189 @@ async function upload(skillPath, options = {}) {
|
|
|
186
268
|
try {
|
|
187
269
|
console.log(chalk.gray('\n正在上传…\n'));
|
|
188
270
|
|
|
189
|
-
const
|
|
271
|
+
const tagsRaw = tags.map((t) => String(t).trim()).filter(Boolean);
|
|
272
|
+
const tagsFinal = normalizeTagsForUpload(tagsRaw);
|
|
273
|
+
|
|
274
|
+
if (tagsFinal.length !== tagsRaw.length) {
|
|
275
|
+
console.log(chalk.yellow(`标签规范化:${tagsRaw.length} → ${tagsFinal.length}(拆分复合词/去重)`));
|
|
276
|
+
console.log(chalk.gray(` 原始: [${tagsRaw.join(', ')}]`));
|
|
277
|
+
console.log(chalk.gray(` 规范: [${tagsFinal.join(', ')}]`));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Resolve tags against SkillTag vocabulary (batch)
|
|
281
|
+
console.log(chalk.gray('\nResolving tags...'));
|
|
282
|
+
const resolvedTags = [];
|
|
283
|
+
try {
|
|
284
|
+
const resolveResult = await apiClient.resolveSkillTags(tagsFinal);
|
|
285
|
+
if (resolveResult.code === 200 && resolveResult.data) {
|
|
286
|
+
const unmatchedTags = [];
|
|
287
|
+
|
|
288
|
+
for (const [rawTag, result] of Object.entries(resolveResult.data)) {
|
|
289
|
+
if (result.status === 'matched' && result.tag) {
|
|
290
|
+
resolvedTags.push(result.tag.nameEn);
|
|
291
|
+
console.log(chalk.gray(` ${rawTag} -> ${result.tag.nameZh} (${result.tag.nameEn})`));
|
|
292
|
+
} else {
|
|
293
|
+
// unmatched: collect for later decision
|
|
294
|
+
const suggestions = result.suggestions || [];
|
|
295
|
+
unmatchedTags.push({ rawTag, suggestions });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle unmatched tags interactively
|
|
300
|
+
if (unmatchedTags.length > 0) {
|
|
301
|
+
console.log(chalk.yellow(`\n以下 ${unmatchedTags.length} 个标签在数据库中未找到:`));
|
|
302
|
+
for (const { rawTag, suggestions } of unmatchedTags) {
|
|
303
|
+
console.log(chalk.yellow(`\n ✗ "${rawTag}" 不存在`));
|
|
304
|
+
if (suggestions.length > 0) {
|
|
305
|
+
console.log(chalk.gray(` 数据库中可能相关的标签:`));
|
|
306
|
+
for (const s of suggestions) {
|
|
307
|
+
console.log(chalk.gray(` - ${s.nameZh} (${s.nameEn}) [id=${s.id}]`));
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
console.log(chalk.gray(` 未找到相似标签`));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Let user/AI decide for each unmatched tag
|
|
315
|
+
for (const { rawTag, suggestions } of unmatchedTags) {
|
|
316
|
+
const choices = [
|
|
317
|
+
{ name: `创建新标签 "${rawTag}"`, value: 'create' },
|
|
318
|
+
...suggestions.map(s => ({
|
|
319
|
+
name: `复用 "${s.nameZh}" (${s.nameEn})`,
|
|
320
|
+
value: s.nameEn
|
|
321
|
+
})),
|
|
322
|
+
{ name: '跳过此标签', value: 'skip' }
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
// In non-interactive mode, auto-create if no suggestions, otherwise ask
|
|
326
|
+
if (options.yes) {
|
|
327
|
+
// Auto mode: 仅在建议足够匹配时才复用,否则创建新 tag
|
|
328
|
+
// 匹配条件:建议的 nameEn 与原始 tag 完全相同(大小写不敏感),
|
|
329
|
+
// 或原始 tag 作为独立单词出现在建议中(如 "AI" 匹配 "AI Agent")
|
|
330
|
+
const strongMatch = suggestions.find(s => {
|
|
331
|
+
const sLower = s.nameEn.toLowerCase();
|
|
332
|
+
const tLower = rawTag.toLowerCase();
|
|
333
|
+
if (sLower === tLower) return true;
|
|
334
|
+
// 原始 tag 作为独立单词出现在建议中,且建议不超过 2 个单词
|
|
335
|
+
// (避免 "SES" 匹配到 "email-ses-smtp" 这类长复合词)
|
|
336
|
+
const parts = sLower.split(/[-_\s]+/);
|
|
337
|
+
if (parts.length <= 2 && parts.includes(tLower)) return true;
|
|
338
|
+
return false;
|
|
339
|
+
});
|
|
340
|
+
if (strongMatch) {
|
|
341
|
+
resolvedTags.push(strongMatch.nameEn);
|
|
342
|
+
console.log(chalk.gray(` [auto] ${rawTag} -> ${strongMatch.nameEn} (匹配: ${strongMatch.nameZh})`));
|
|
343
|
+
} else if (suggestions.length > 0) {
|
|
344
|
+
// 有建议但不够强 → 仍然创建新 tag(更保守,避免错误合并)
|
|
345
|
+
const nameEn = rawTag.replace(/\s+/g, '-').toLowerCase();
|
|
346
|
+
try {
|
|
347
|
+
const createResult = await apiClient.createSkillTag(rawTag, nameEn);
|
|
348
|
+
if (createResult.code === 200 && createResult.data) {
|
|
349
|
+
resolvedTags.push(createResult.data.nameEn);
|
|
350
|
+
console.log(chalk.green(` [auto] 已创建标签: ${rawTag} (${createResult.data.nameEn})`));
|
|
351
|
+
} else {
|
|
352
|
+
resolvedTags.push(rawTag);
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
resolvedTags.push(rawTag);
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
const nameEn = rawTag.replace(/\s+/g, '-').toLowerCase();
|
|
359
|
+
try {
|
|
360
|
+
const createResult = await apiClient.createSkillTag(rawTag, nameEn);
|
|
361
|
+
if (createResult.code === 200 && createResult.data) {
|
|
362
|
+
resolvedTags.push(createResult.data.nameEn);
|
|
363
|
+
console.log(chalk.green(` [auto] 已创建标签: ${rawTag} (${createResult.data.nameEn})`));
|
|
364
|
+
} else {
|
|
365
|
+
console.log(chalk.yellow(` [auto] 跳过: ${rawTag}`));
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
console.log(chalk.yellow(` [auto] 创建失败,跳过: ${rawTag}`));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const { action } = await inquirer.prompt([
|
|
375
|
+
{
|
|
376
|
+
type: 'list',
|
|
377
|
+
name: 'action',
|
|
378
|
+
message: `如何处理标签 "${rawTag}"?`,
|
|
379
|
+
choices
|
|
380
|
+
}
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
if (action === 'create') {
|
|
384
|
+
const nameEn = rawTag.replace(/\s+/g, '-').toLowerCase();
|
|
385
|
+
try {
|
|
386
|
+
const createResult = await apiClient.createSkillTag(rawTag, nameEn);
|
|
387
|
+
if (createResult.code === 200 && createResult.data) {
|
|
388
|
+
resolvedTags.push(createResult.data.nameEn);
|
|
389
|
+
console.log(chalk.green(` 已创建: ${rawTag} -> ${createResult.data.nameEn}`));
|
|
390
|
+
} else {
|
|
391
|
+
console.log(chalk.yellow(` 创建失败,跳过: ${rawTag}`));
|
|
392
|
+
}
|
|
393
|
+
} catch (createErr) {
|
|
394
|
+
// Retry search (another process may have created it concurrently)
|
|
395
|
+
try {
|
|
396
|
+
const retryResult = await apiClient.searchSkillTags(rawTag);
|
|
397
|
+
if (retryResult.code === 200 && Array.isArray(retryResult.data) && retryResult.data.length > 0) {
|
|
398
|
+
resolvedTags.push(retryResult.data[0].nameEn);
|
|
399
|
+
console.log(chalk.gray(` 已存在(重试命中): ${rawTag} -> ${retryResult.data[0].nameEn}`));
|
|
400
|
+
} else {
|
|
401
|
+
console.log(chalk.yellow(` 创建失败,跳过: ${rawTag}`));
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
console.log(chalk.yellow(` 创建失败,跳过: ${rawTag}`));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} else if (action === 'skip') {
|
|
408
|
+
console.log(chalk.gray(` 已跳过: ${rawTag}`));
|
|
409
|
+
} else {
|
|
410
|
+
// Reuse existing tag
|
|
411
|
+
resolvedTags.push(action);
|
|
412
|
+
console.log(chalk.gray(` 复用: ${rawTag} -> ${action}`));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
// Fallback to original behavior
|
|
418
|
+
console.log(chalk.yellow('批量解析失败,回退到逐个解析...'));
|
|
419
|
+
for (const tag of tagsFinal) {
|
|
420
|
+
try {
|
|
421
|
+
const searchResult = await apiClient.searchSkillTags(tag);
|
|
422
|
+
if (searchResult.code === 200 && Array.isArray(searchResult.data) && searchResult.data.length > 0) {
|
|
423
|
+
resolvedTags.push(searchResult.data[0].nameEn);
|
|
424
|
+
} else {
|
|
425
|
+
resolvedTags.push(tag);
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
resolvedTags.push(tag);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
// Fallback: network error or endpoint not available
|
|
434
|
+
console.log(chalk.yellow('批量解析接口不可用,回退到逐个解析...'));
|
|
435
|
+
for (const tag of tagsFinal) {
|
|
436
|
+
try {
|
|
437
|
+
const searchResult = await apiClient.searchSkillTags(tag);
|
|
438
|
+
if (searchResult.code === 200 && Array.isArray(searchResult.data) && searchResult.data.length > 0) {
|
|
439
|
+
resolvedTags.push(searchResult.data[0].nameEn);
|
|
440
|
+
} else {
|
|
441
|
+
resolvedTags.push(tag);
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
resolvedTags.push(tag);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
190
449
|
const data = {
|
|
191
450
|
name: String(name).trim(),
|
|
192
451
|
purpose: String(description).trim(),
|
|
193
452
|
rootUrl: String(rootUrl).trim(),
|
|
194
|
-
tags:
|
|
453
|
+
tags: resolvedTags,
|
|
195
454
|
usageExamples,
|
|
196
455
|
model: modelFinal
|
|
197
456
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"model": "
|
|
2
|
+
"model": "claude-sonnet-4-6",
|
|
3
3
|
"examples": [
|
|
4
4
|
{
|
|
5
5
|
"prompt": "请根据我仓库里的 SKILL.md 帮我执行上传,并告诉我你要确认哪些字段。",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"content": "我会先读取 `SKILL.md` 的 frontmatter,列出缺失项;请你确认一条「最终用户会如何提问」的测试案例。然后运行 `skill-market-cli upload <skill 目录>`,在交互中补全字段并自动采集轨迹后提交到 Skill Market。"
|
|
21
21
|
}
|
|
22
22
|
],
|
|
23
|
-
"model": "
|
|
23
|
+
"model": "claude-sonnet-4-6"
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|
|
@@ -5,7 +5,7 @@ tags:
|
|
|
5
5
|
- skill-market
|
|
6
6
|
- cli
|
|
7
7
|
- upload
|
|
8
|
-
model:
|
|
8
|
+
model: claude-sonnet-4-6
|
|
9
9
|
rootUrl: https://raw.githubusercontent.com/LSTM-Kirigaya/skill-market-cli/refs/heads/main/src/skills/skill-market-upload/SKILL.md
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -20,11 +20,17 @@ rootUrl: https://raw.githubusercontent.com/LSTM-Kirigaya/skill-market-cli/refs/h
|
|
|
20
20
|
- **方法 1**:在命令行运行 `skill-market-cli login`
|
|
21
21
|
- **方法 2**:打开 https://kirigaya.cn/profile/tokens ,自己创建 Personal Access Token,然后运行 `skill-market-cli token set <your-token>`
|
|
22
22
|
2. **上传走 AI 渠道**:CLI 使用 `POST /api/skill/ai/upload`,要求 **全部字段非空**,且 **`tags`、`usageExamples` 不得为空数组**。
|
|
23
|
-
3.
|
|
23
|
+
3. **模型字段**:`model` 字段必须填写你当前实际使用的模型名称(如 `claude-sonnet-4-6`、`claude-opus-4-7` 等),**禁止**使用 `deepseek-chat` 作为默认值。这个字段代表该 Skill 推荐使用的模型,应与实际运行环境一致。
|
|
24
|
+
4. **标签管理(重要)**:在上传前,**必须**通过标签 API 搜索和管理标签,避免创建语义相近的重复 tag:
|
|
25
|
+
- **搜索标签**:`GET /api/skill/tags/search?keyword=xxx` — 按关键词搜索已有标签(支持中英文)
|
|
26
|
+
- **创建标签**:`POST /api/skill/tags/create` body: `{"nameZh":"中文名","nameEn":"EnglishName"}` — 创建新标签(需超级用户权限)
|
|
27
|
+
- **列出所有标签**:`GET /api/skill/tags` — 获取全部已有标签
|
|
28
|
+
- 工作流:先搜索 → 从结果中挑选匹配的 → 若无匹配则创建新标签 → 将选中的标签名填入 `tags` 字段
|
|
29
|
+
5. **用户案例(必填)**:`usageExamples` 中每一项必须包含:
|
|
24
30
|
- **`prompt`**:终端用户会如何向该 Skill 提问(由用户或你根据上下文代写,但必须经用户确认)。
|
|
25
31
|
- **`aiResponses`**:一次「示例运行」采集到的轨迹(thinking / toolcall / message)。上传命令会在本地 **自动调用采集逻辑** 生成(当前为可替换的模拟实现,结构需与后端一致)。
|
|
26
|
-
- **`model
|
|
27
|
-
|
|
32
|
+
- **`model`**:推荐模型名(应与第 3 条的模型字段一致)。
|
|
33
|
+
6. **无法仅从文件推断的字段**:若 SKILL.md 未写全,上传流程会 **交互询问**:名称、描述、标签、`rootUrl`、推荐模型等。Agent 应结合仓库上下文、README、用户口述 **帮用户预填**,并在询问环节确认。
|
|
28
34
|
|
|
29
35
|
## 推荐工作流(Agent)
|
|
30
36
|
|
|
@@ -33,13 +39,20 @@ rootUrl: https://raw.githubusercontent.com/LSTM-Kirigaya/skill-market-cli/refs/h
|
|
|
33
39
|
- 若未登录,向用户说明两种登录方式(`skill-market-cli login` 或前往 https://kirigaya.cn/profile/tokens 创建 token),并在用户完成登录后继续。
|
|
34
40
|
2. 确认仓库中存在 **`SKILL.md`**(目录则路径指向该目录)。
|
|
35
41
|
3. 读取 frontmatter,整理候选:`name`、`purpose`/`description`、`tags`、`model`、`rootUrl`。
|
|
36
|
-
4.
|
|
37
|
-
5
|
|
42
|
+
4. **管理标签**:
|
|
43
|
+
- 根据 Skill 的用途提取 2–5 个关键词。
|
|
44
|
+
- 对每个关键词,调用 `GET /api/skill/tags/search?keyword=<关键词>` 搜索已有标签。
|
|
45
|
+
- 从搜索结果中选择语义最匹配的标签(优先使用已有标签,避免重复创建)。
|
|
46
|
+
- 若搜索结果中无合适标签,调用 `POST /api/skill/tags/create` 创建新标签(需同时提供 `nameZh` 和 `nameEn`)。
|
|
47
|
+
- 最终确定的标签列表填入 `--tags` 参数(逗号分隔),如 `--tags "AI Agent,CLI,Debug"`。
|
|
48
|
+
5. 与用户确认 **至少一条「用户会如何提问」的测试案例**(可多轮补充)。案例文本即 `prompt`。
|
|
49
|
+
6. **确定模型**:使用你当前实际运行的模型名称(如 `claude-sonnet-4-6`)作为 `--model` 参数,不要使用 `deepseek-chat`。
|
|
50
|
+
7. 在终端执行上传(勿省略路径):
|
|
38
51
|
```bash
|
|
39
|
-
skill-market-cli upload <path-to-skill-dir-or-SKILL.md>
|
|
52
|
+
skill-market-cli upload <path-to-skill-dir-or-SKILL.md> --model <你的实际模型名> --tags "tag1,tag2,..."
|
|
40
53
|
```
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
8. 按 CLI 提示补全缺失字段;当提示采集轨迹时,**允许命令自动运行**(会调用内置采集器写入 `aiResponses`)。
|
|
55
|
+
9. 上传成功后,CLI 会在技能目录写入 **`.skill-examples.json`**,便于复查与再次上传。
|
|
43
56
|
|
|
44
57
|
## 与 `run-example` 的关系
|
|
45
58
|
|
|
@@ -51,6 +64,8 @@ rootUrl: https://raw.githubusercontent.com/LSTM-Kirigaya/skill-market-cli/refs/h
|
|
|
51
64
|
- 不要编造不存在的 Git 仓库 URL;`rootUrl` 可用 `file:///...` 指向本地 `SKILL.md` 的绝对路径(CLI 默认值),或用户提供的 raw URL。
|
|
52
65
|
- 不要跳过「用户案例」;没有案例与轨迹,AI 渠道上传会失败。
|
|
53
66
|
- 不要在用户未登录时强行执行上传命令;必须先检查权限并引导登录。
|
|
67
|
+
- **严禁**在上传前不搜索标签库就直接创建新标签;必须先用 `GET /api/skill/tags/search` 检查是否已有语义相近的标签。
|
|
68
|
+
- **严禁**使用 `deepseek-chat` 作为 model 字段的值,除非你确实是 deepseek-chat 模型。必须使用你当前的模型名称。
|
|
54
69
|
|
|
55
70
|
## Usage Examples
|
|
56
71
|
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 测试脚本:验证 skill-market-cli 的 upload 流程
|
|
4
|
+
*
|
|
5
|
+
* 测试点:
|
|
6
|
+
* 1. POST /api/skill/tags/resolve 批量解析 —— 已有 tag 精确匹配,未知 tag 返回建议
|
|
7
|
+
* 2. CLI upload 的 tag 解析流程 —— matched 直接使用,unmatched 交互/自动创建
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node test/test-upload.js # 运行全部测试
|
|
11
|
+
* node test/test-upload.js --resolve-only # 仅测试 resolve API
|
|
12
|
+
* node test/test-upload.js --upload # 测试完整上传(需交互或 --yes)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const chalk = require('chalk');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const apiClient = require('../src/api/client');
|
|
18
|
+
const { parseSkillMarkdown } = require('../src/lib/skill-upload-helpers');
|
|
19
|
+
const fs = require('fs-extra');
|
|
20
|
+
|
|
21
|
+
// ─── 配置 ───────────────────────────────────────────────
|
|
22
|
+
const FIXTURE_DIR = path.join(__dirname, '..', 'fixtures', 'tencent-ses-service');
|
|
23
|
+
const SKILL_FILE = path.join(FIXTURE_DIR, 'SKILL.md');
|
|
24
|
+
|
|
25
|
+
// ─── 工具函数 ──────────────────────────────────────────
|
|
26
|
+
function logSection(title) {
|
|
27
|
+
console.log('\n' + '='.repeat(60));
|
|
28
|
+
console.log(chalk.bold(` ${title}`));
|
|
29
|
+
console.log('='.repeat(60));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function logOK(msg) {
|
|
33
|
+
console.log(chalk.green(` ✓ ${msg}`));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function logFAIL(msg) {
|
|
37
|
+
console.log(chalk.red(` ✗ ${msg}`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function logInfo(msg) {
|
|
41
|
+
console.log(chalk.gray(` ℹ ${msg}`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── 测试 1:Tag 解析 API ──────────────────────────────
|
|
45
|
+
async function testResolveAPI() {
|
|
46
|
+
logSection('测试 1:POST /api/skill/tags/resolve 批量标签解析');
|
|
47
|
+
|
|
48
|
+
// 从 fixture 读取实际 tags
|
|
49
|
+
const content = fs.readFileSync(SKILL_FILE, 'utf-8');
|
|
50
|
+
const { frontmatter } = parseSkillMarkdown(content);
|
|
51
|
+
const testTags = (frontmatter && frontmatter.tags) ? frontmatter.tags : [];
|
|
52
|
+
|
|
53
|
+
// 额外加入一个确定不存在且无建议的 tag
|
|
54
|
+
const uniqueTag = 'zzz-unique-test-' + Date.now();
|
|
55
|
+
testTags.push(uniqueTag);
|
|
56
|
+
|
|
57
|
+
console.log(chalk.gray(` 输入标签: ${testTags.join(', ')}`));
|
|
58
|
+
|
|
59
|
+
let result;
|
|
60
|
+
try {
|
|
61
|
+
result = await apiClient.resolveSkillTags(testTags);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
logFAIL(`API 调用失败: ${err.message}`);
|
|
64
|
+
return { passed: 0, failed: 1 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (result.code !== 200) {
|
|
68
|
+
logFAIL(`API 返回非 200: ${JSON.stringify(result)}`);
|
|
69
|
+
return { passed: 0, failed: 1 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const data = result.data;
|
|
73
|
+
let passed = 0;
|
|
74
|
+
let failed = 0;
|
|
75
|
+
let matchedCount = 0;
|
|
76
|
+
let unmatchedCount = 0;
|
|
77
|
+
|
|
78
|
+
// 遍历所有返回结果,验证数据结构
|
|
79
|
+
for (const [rawTag, info] of Object.entries(data)) {
|
|
80
|
+
if (info.status === 'matched') {
|
|
81
|
+
if (info.tag && info.tag.nameEn && info.tag.nameZh) {
|
|
82
|
+
logOK(`"${rawTag}" → matched: ${info.tag.nameZh} (${info.tag.nameEn})`);
|
|
83
|
+
matchedCount++;
|
|
84
|
+
} else {
|
|
85
|
+
logFAIL(`"${rawTag}" status=matched 但缺少 tag 详情`);
|
|
86
|
+
failed++;
|
|
87
|
+
}
|
|
88
|
+
} else if (info.status === 'unmatched') {
|
|
89
|
+
const suggestions = info.suggestions || [];
|
|
90
|
+
if (suggestions.length > 0) {
|
|
91
|
+
logOK(`"${rawTag}" → unmatched,${suggestions.length} 条建议: ${suggestions.map(s => s.nameZh).join(', ')}`);
|
|
92
|
+
} else {
|
|
93
|
+
logOK(`"${rawTag}" → unmatched,无相似建议`);
|
|
94
|
+
}
|
|
95
|
+
unmatchedCount++;
|
|
96
|
+
} else {
|
|
97
|
+
logFAIL(`"${rawTag}" 未知状态: ${info.status}`);
|
|
98
|
+
failed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 验证唯一 tag 必然是 unmatched
|
|
103
|
+
const uniqueKey = Object.keys(data).find(k => k === uniqueTag);
|
|
104
|
+
if (uniqueKey && data[uniqueKey].status === 'unmatched') {
|
|
105
|
+
logOK(`唯一测试 tag "${uniqueTag}" 正确返回 unmatched`);
|
|
106
|
+
passed++;
|
|
107
|
+
} else {
|
|
108
|
+
logFAIL(`唯一测试 tag "${uniqueTag}" 应 unmatched`);
|
|
109
|
+
failed++;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
passed += matchedCount + unmatchedCount; // 每个返回都算通过
|
|
113
|
+
logInfo(`统计: ${matchedCount} 匹配, ${unmatchedCount} 未匹配`);
|
|
114
|
+
|
|
115
|
+
console.log(chalk.bold(`\n 结果: ${chalk.green(passed + ' 通过')} / ${chalk.red(failed + ' 失败')}`));
|
|
116
|
+
return { passed, failed, data };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── 测试 2:SKILL.md 解析 ─────────────────────────────
|
|
120
|
+
async function testParseSkillMD() {
|
|
121
|
+
logSection('测试 2:SKILL.md 解析(frontmatter + Usage Examples)');
|
|
122
|
+
|
|
123
|
+
if (!fs.existsSync(SKILL_FILE)) {
|
|
124
|
+
logFAIL(`SKILL.md 不存在: ${SKILL_FILE}`);
|
|
125
|
+
return { passed: 0, failed: 1 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const content = fs.readFileSync(SKILL_FILE, 'utf-8');
|
|
129
|
+
const { frontmatter, examples } = parseSkillMarkdown(content);
|
|
130
|
+
|
|
131
|
+
let passed = 0;
|
|
132
|
+
let failed = 0;
|
|
133
|
+
|
|
134
|
+
if (frontmatter) {
|
|
135
|
+
logOK(`frontmatter 解析成功`);
|
|
136
|
+
logInfo(` name: ${frontmatter.name}`);
|
|
137
|
+
logInfo(` purpose: ${(frontmatter.purpose || '').substring(0, 50)}...`);
|
|
138
|
+
logInfo(` tags: [${(frontmatter.tags || []).join(', ')}]`);
|
|
139
|
+
logInfo(` model: ${frontmatter.model}`);
|
|
140
|
+
logInfo(` rootUrl: ${frontmatter.rootUrl}`);
|
|
141
|
+
passed++;
|
|
142
|
+
} else {
|
|
143
|
+
logFAIL('frontmatter 解析失败');
|
|
144
|
+
failed++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (examples.length > 0) {
|
|
148
|
+
logOK(`解析到 ${examples.length} 条 Usage Example`);
|
|
149
|
+
passed++;
|
|
150
|
+
} else {
|
|
151
|
+
logFAIL('未解析到 Usage Example');
|
|
152
|
+
failed++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(chalk.bold(`\n 结果: ${chalk.green(passed + ' 通过')} / ${chalk.red(failed + ' 失败')}`));
|
|
156
|
+
return { passed, failed, frontmatter, examples };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── 测试 3:模拟 CLI upload 的 tag 解析流程 ─────────────
|
|
160
|
+
async function testTagResolutionFlow() {
|
|
161
|
+
logSection('测试 3:模拟 CLI upload 中的 tag 解析流程');
|
|
162
|
+
|
|
163
|
+
const content = fs.readFileSync(SKILL_FILE, 'utf-8');
|
|
164
|
+
const { frontmatter } = parseSkillMarkdown(content);
|
|
165
|
+
const tagsFromMD = frontmatter.tags || [];
|
|
166
|
+
|
|
167
|
+
console.log(chalk.gray(` 从 SKILL.md 解析到的 tags: [${tagsFromMD.join(', ')}]`));
|
|
168
|
+
|
|
169
|
+
// Step 1: 批量 resolve
|
|
170
|
+
let resolveResult;
|
|
171
|
+
try {
|
|
172
|
+
resolveResult = await apiClient.resolveSkillTags(tagsFromMD);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logFAIL(`批量解析失败: ${err.message}`);
|
|
175
|
+
return { passed: 0, failed: 1 };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (resolveResult.code !== 200) {
|
|
179
|
+
logFAIL(`批量解析返回非 200`);
|
|
180
|
+
return { passed: 0, failed: 1 };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const data = resolveResult.data;
|
|
184
|
+
const matched = [];
|
|
185
|
+
const unmatched = [];
|
|
186
|
+
let passed = 0;
|
|
187
|
+
let failed = 0;
|
|
188
|
+
|
|
189
|
+
for (const [rawTag, result] of Object.entries(data)) {
|
|
190
|
+
if (result.status === 'matched' && result.tag) {
|
|
191
|
+
matched.push({ raw: rawTag, nameEn: result.tag.nameEn, nameZh: result.tag.nameZh });
|
|
192
|
+
logOK(`"${rawTag}" → 匹配到 "${result.tag.nameZh}" (${result.tag.nameEn})`);
|
|
193
|
+
} else {
|
|
194
|
+
const suggestions = result.suggestions || [];
|
|
195
|
+
unmatched.push({ raw: rawTag, suggestions });
|
|
196
|
+
if (suggestions.length > 0) {
|
|
197
|
+
logInfo(`"${rawTag}" → 未匹配,有 ${suggestions.length} 条建议`);
|
|
198
|
+
} else {
|
|
199
|
+
logInfo(`"${rawTag}" → 未匹配,无建议`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 验证:所有返回的 tag 状态均合法
|
|
205
|
+
for (const [rawTag, info] of Object.entries(data)) {
|
|
206
|
+
if (info.status === 'matched' && info.tag) {
|
|
207
|
+
passed++;
|
|
208
|
+
} else if (info.status === 'unmatched') {
|
|
209
|
+
passed++;
|
|
210
|
+
} else {
|
|
211
|
+
logFAIL(`"${rawTag}" 状态异常: ${info.status}`);
|
|
212
|
+
failed++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
logInfo(`resolve 结果: ${matched.length} 匹配, ${unmatched.length} 未匹配`);
|
|
217
|
+
|
|
218
|
+
// 模拟 --yes 自动模式:matched 直接用,unmatched 自动创建
|
|
219
|
+
logInfo('\n 模拟 --yes 自动模式:');
|
|
220
|
+
const resolvedTags = matched.map(m => m.nameEn);
|
|
221
|
+
|
|
222
|
+
for (const u of unmatched) {
|
|
223
|
+
if (u.suggestions.length > 0) {
|
|
224
|
+
// 有建议则取第一个
|
|
225
|
+
const pick = u.suggestions[0].nameEn;
|
|
226
|
+
resolvedTags.push(pick);
|
|
227
|
+
logInfo(` [auto] "${u.raw}" → 复用建议: ${pick}`);
|
|
228
|
+
} else {
|
|
229
|
+
// 无建议则尝试创建
|
|
230
|
+
const nameEn = u.raw.replace(/\s+/g, '-').toLowerCase();
|
|
231
|
+
try {
|
|
232
|
+
const createResult = await apiClient.createSkillTag(u.raw, nameEn);
|
|
233
|
+
if (createResult.code === 200 && createResult.data) {
|
|
234
|
+
resolvedTags.push(createResult.data.nameEn);
|
|
235
|
+
logOK(` [auto] "${u.raw}" → 已创建: ${createResult.data.nameEn}`);
|
|
236
|
+
passed++;
|
|
237
|
+
} else {
|
|
238
|
+
logFAIL(` [auto] "${u.raw}" → 创建失败: ${JSON.stringify(createResult)}`);
|
|
239
|
+
failed++;
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
logFAIL(` [auto] "${u.raw}" → 创建异常: ${err.message}`);
|
|
243
|
+
failed++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log(chalk.gray(`\n 最终提交的 tags: [${resolvedTags.join(', ')}]`));
|
|
249
|
+
|
|
250
|
+
console.log(chalk.bold(`\n 结果: ${chalk.green(passed + ' 通过')} / ${chalk.red(failed + ' 失败')}`));
|
|
251
|
+
return { passed, failed, matched, unmatched, resolvedTags };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── 主入口 ─────────────────────────────────────────────
|
|
255
|
+
async function main() {
|
|
256
|
+
const args = process.argv.slice(2);
|
|
257
|
+
const resolveOnly = args.includes('--resolve-only');
|
|
258
|
+
const upload = args.includes('--upload');
|
|
259
|
+
|
|
260
|
+
console.log(chalk.bold.cyan('╔══════════════════════════════════════════════════════╗'));
|
|
261
|
+
console.log(chalk.bold.cyan('║ Skill Market CLI — Upload 流程测试 ║'));
|
|
262
|
+
console.log(chalk.bold.cyan('╚══════════════════════════════════════════════════════╝'));
|
|
263
|
+
console.log(chalk.gray(` 测试 fixture: ${SKILL_FILE}`));
|
|
264
|
+
|
|
265
|
+
let totalPassed = 0;
|
|
266
|
+
let totalFailed = 0;
|
|
267
|
+
|
|
268
|
+
// 测试 1: Resolve API
|
|
269
|
+
if (!upload) {
|
|
270
|
+
const r1 = await testResolveAPI();
|
|
271
|
+
totalPassed += r1.passed;
|
|
272
|
+
totalFailed += r1.failed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 测试 2: SKILL.md 解析
|
|
276
|
+
if (!upload) {
|
|
277
|
+
const r2 = await testParseSkillMD();
|
|
278
|
+
totalPassed += r2.passed;
|
|
279
|
+
totalFailed += r2.failed;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 测试 3: Tag 解析流程模拟
|
|
283
|
+
const r3 = await testTagResolutionFlow();
|
|
284
|
+
totalPassed += r3.passed;
|
|
285
|
+
totalFailed += r3.failed;
|
|
286
|
+
|
|
287
|
+
// 如果有 --upload,实际执行 CLI upload
|
|
288
|
+
if (upload) {
|
|
289
|
+
logSection('测试 4:实际 CLI Upload(--yes 非交互模式)');
|
|
290
|
+
const { execSync } = require('child_process');
|
|
291
|
+
try {
|
|
292
|
+
const cmd = `node bin/skill-market-cli.js upload "${FIXTURE_DIR}" --yes`;
|
|
293
|
+
logInfo(`执行: ${cmd}`);
|
|
294
|
+
const output = execSync(cmd, { cwd: path.join(__dirname, '..'), encoding: 'utf-8', timeout: 60000 });
|
|
295
|
+
console.log(output);
|
|
296
|
+
logOK('Upload 命令执行成功');
|
|
297
|
+
totalPassed++;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
logFAIL(`Upload 命令执行失败: ${err.message}`);
|
|
300
|
+
if (err.stdout) console.log(chalk.gray(err.stdout.toString()));
|
|
301
|
+
if (err.stderr) console.log(chalk.red(err.stderr.toString()));
|
|
302
|
+
totalFailed++;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── 总结 ──────────────────────────────────────────
|
|
307
|
+
logSection('测试总结');
|
|
308
|
+
const total = totalPassed + totalFailed;
|
|
309
|
+
if (totalFailed === 0) {
|
|
310
|
+
console.log(chalk.green.bold(` ✓ 全部 ${total} 项测试通过`));
|
|
311
|
+
} else {
|
|
312
|
+
console.log(chalk.yellow.bold(` ${totalPassed}/${total} 通过, ${totalFailed} 失败`));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
process.exit(totalFailed > 0 ? 1 : 0);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
main().catch(err => {
|
|
319
|
+
console.error(chalk.red('测试脚本异常:'), err);
|
|
320
|
+
process.exit(1);
|
|
321
|
+
});
|