@yottameta/yotta-skills-plugin 0.26.0 → 0.27.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/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/yotta-skills/SKILL.md +13 -6
- package/skills/yotta-skills/assets/view.html +12 -5
- package/skills/yotta-skills/bin/yotta-skills.js +74 -12
- package/skills/yotta-skills/lib/hub-adopt.js +16 -0
- package/skills/yotta-skills/lib/hub-view-server.js +14 -2
- package/skills/yotta-skills/lib/hub.js +430 -4
- package/skills/yotta-skills/lib/registry-fetch.js +7 -0
- package/skills/yotta-skills/lib/skills-scan.js +41 -0
- package/skills/yotta-skills/references/hub.md +24 -1
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
|
@@ -21,6 +21,20 @@ const STATE_FILE = '.yotta-hub.json';
|
|
|
21
21
|
const LINKS_FILE = '.yotta-links.json';
|
|
22
22
|
const AUDIT_FILE = '.yotta-hub-audit.jsonl';
|
|
23
23
|
const SKILL_FILE = 'SKILL.md';
|
|
24
|
+
const TRASH_RETENTION_DAYS = 7;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hub 家族范围 = 全家清单(skills.json,27)+ 特殊家族(5)。
|
|
28
|
+
* 特殊家族不进一次性安装清单;仅参与 Hub 安装 / 更新 / 收敛 / 体检,
|
|
29
|
+
* 版本跟随各自 npm latest(由安装管线在运行时解析)。
|
|
30
|
+
*/
|
|
31
|
+
const HUB_FAMILY_EXTRAS = [
|
|
32
|
+
{ slug: 'yotta-dev-mcp', name: '元开', pkg: '@yottameta/yotta-dev-mcp', version: 'latest' },
|
|
33
|
+
{ slug: 'yotta-partner', name: '元伴', pkg: '@yottameta/yotta-partner', version: 'latest' },
|
|
34
|
+
{ slug: 'yotta-present', name: '元呈', pkg: '@yottameta/yotta-present', version: 'latest' },
|
|
35
|
+
{ slug: 'yotta-skills', name: '元阁', pkg: '@yottameta/yotta-skills', version: 'latest' },
|
|
36
|
+
{ slug: 'yotta-verify-mcp', name: '元信MCP', pkg: '@yottameta/yotta-verify-mcp', version: 'latest' },
|
|
37
|
+
];
|
|
24
38
|
|
|
25
39
|
const HASH_SKIP = new Set([
|
|
26
40
|
'.git', 'node_modules', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
@@ -226,10 +240,141 @@ function manifestSlugs(manifest) {
|
|
|
226
240
|
return set;
|
|
227
241
|
}
|
|
228
242
|
|
|
243
|
+
/** 家族 slug 集合:接受清单数组 / Set / {skills} 结构,并始终并入特殊家族 5 个。 */
|
|
244
|
+
function familySlugSet(manifest) {
|
|
245
|
+
const set = new Set();
|
|
246
|
+
if (manifest instanceof Set) {
|
|
247
|
+
for (const slug of manifest) set.add(String(slug));
|
|
248
|
+
} else {
|
|
249
|
+
const list = Array.isArray(manifest) ? manifest : (manifest && manifest.skills) || [];
|
|
250
|
+
for (const item of list) {
|
|
251
|
+
if (typeof item === 'string') set.add(item);
|
|
252
|
+
else if (item && item.slug) set.add(item.slug);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const item of HUB_FAMILY_EXTRAS) set.add(item.slug);
|
|
256
|
+
return set;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function compareVersions(left, right) {
|
|
260
|
+
return scanLib.compareSemver(left, right);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readSkillMeta(dir) {
|
|
264
|
+
try {
|
|
265
|
+
const text = fs.readFileSync(path.join(dir, SKILL_FILE), 'utf8');
|
|
266
|
+
const fm = scanLib.parseFrontmatter(text) || {};
|
|
267
|
+
return {
|
|
268
|
+
name: String(fm.name || '').trim(),
|
|
269
|
+
version: fm.version ? String(fm.version).trim() : '',
|
|
270
|
+
};
|
|
271
|
+
} catch (_) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveTrashRoot(hubDir, opts) {
|
|
277
|
+
const options = opts || {};
|
|
278
|
+
const explicit = options.trashRoot || options.trashDir || process.env.YOTTA_SKILLS_TRASH;
|
|
279
|
+
if (explicit) return path.resolve(explicit);
|
|
280
|
+
return path.join(path.dirname(path.resolve(hubDir)), 'trash');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function trashRunStamp(now) {
|
|
284
|
+
const d = now instanceof Date ? now : new Date();
|
|
285
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
286
|
+
return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' +
|
|
287
|
+
pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '-' + process.pid;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function safeHostName(value) {
|
|
291
|
+
const cleaned = String(value || '')
|
|
292
|
+
.replace(/[\\/:*?"<>|\s]+/g, '-')
|
|
293
|
+
.replace(/^-+|-+$/g, '');
|
|
294
|
+
return cleaned || 'host';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 清理回收站中超过保留期的条目(best-effort;返回清理数量)。 */
|
|
298
|
+
function pruneTrash(trashRoot, options) {
|
|
299
|
+
const opts = options || {};
|
|
300
|
+
const days = Number(opts.days) > 0 ? Number(opts.days) : TRASH_RETENTION_DAYS;
|
|
301
|
+
const now = opts.now instanceof Date ? opts.now.getTime() : Date.now();
|
|
302
|
+
let pruned = 0;
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = fs.readdirSync(trashRoot, { withFileTypes: true });
|
|
306
|
+
} catch (_) {
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (!entry.isDirectory()) continue;
|
|
311
|
+
const full = path.join(trashRoot, entry.name);
|
|
312
|
+
let stat;
|
|
313
|
+
try {
|
|
314
|
+
stat = fs.statSync(full);
|
|
315
|
+
} catch (_) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (now - stat.mtimeMs > days * 24 * 60 * 60 * 1000) {
|
|
319
|
+
try {
|
|
320
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
321
|
+
pruned += 1;
|
|
322
|
+
} catch (_) { /* keep for next run */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return pruned;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 列出宿主目录中某个家族技能的副本:
|
|
330
|
+
* - 精确同名(slug)的真目录 / 链接;
|
|
331
|
+
* - `slug__*` 前缀命名、frontmatter name === slug 的重命名副本。
|
|
332
|
+
*/
|
|
333
|
+
function listFamilyCopies(targetDir, slug) {
|
|
334
|
+
const copies = [];
|
|
335
|
+
let entries;
|
|
336
|
+
try {
|
|
337
|
+
entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
338
|
+
} catch (_) {
|
|
339
|
+
return copies;
|
|
340
|
+
}
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
if (entry.name.startsWith('.')) continue;
|
|
343
|
+
const isExact = entry.name === slug;
|
|
344
|
+
const isRenamed = entry.name.startsWith(slug + '__');
|
|
345
|
+
if (!isExact && !isRenamed) continue;
|
|
346
|
+
const full = path.join(targetDir, entry.name);
|
|
347
|
+
let stat;
|
|
348
|
+
try {
|
|
349
|
+
stat = fs.lstatSync(full);
|
|
350
|
+
} catch (_) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (stat.isSymbolicLink()) {
|
|
354
|
+
copies.push({ name: entry.name, dir: full, kind: 'link', version: '' });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!stat.isDirectory()) {
|
|
358
|
+
if (isExact) copies.push({ name: entry.name, dir: full, kind: 'file', version: '' });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const meta = readSkillMeta(full);
|
|
362
|
+
if (isRenamed && (!meta || meta.name !== slug)) continue;
|
|
363
|
+
copies.push({
|
|
364
|
+
name: entry.name,
|
|
365
|
+
dir: full,
|
|
366
|
+
kind: 'directory',
|
|
367
|
+
version: meta ? (meta.version || '') : '',
|
|
368
|
+
metaName: meta ? meta.name : '',
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return copies;
|
|
372
|
+
}
|
|
373
|
+
|
|
229
374
|
function syncHubState(hubDir, options) {
|
|
230
375
|
const opts = options || {};
|
|
231
376
|
const previous = readHubState(hubDir);
|
|
232
|
-
const family =
|
|
377
|
+
const family = familySlugSet(opts.manifest);
|
|
233
378
|
const scanned = scanHubSkills(hubDir);
|
|
234
379
|
const upstream = skillsCliLock.readLock({ homeDir: opts.homeDir, env: opts.env });
|
|
235
380
|
const now = nowIso();
|
|
@@ -340,8 +485,20 @@ function linkSkills(options) {
|
|
|
340
485
|
? opts.slugs
|
|
341
486
|
: scanHubSkills(hubDir).map((item) => item.slug);
|
|
342
487
|
const hubBySlug = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
|
|
488
|
+
const family = familySlugSet(opts.familySlugs || opts.manifest);
|
|
489
|
+
const familyFallback = !opts.familySlugs && !opts.manifest;
|
|
343
490
|
const links = readLinkState(hubDir).links.slice();
|
|
344
491
|
const results = [];
|
|
492
|
+
const trashRoot = resolveTrashRoot(hubDir, opts);
|
|
493
|
+
const makeLink = typeof opts.createLink === 'function' ? opts.createLink : createDirLink;
|
|
494
|
+
const stamp = trashRunStamp(opts.now);
|
|
495
|
+
const hostName = safeHostName(opts.label || path.basename(targetDir));
|
|
496
|
+
let prunedTrash = 0;
|
|
497
|
+
if (!dryRun) {
|
|
498
|
+
try {
|
|
499
|
+
prunedTrash = pruneTrash(trashRoot, { days: opts.trashRetentionDays });
|
|
500
|
+
} catch (_) { prunedTrash = 0; }
|
|
501
|
+
}
|
|
345
502
|
|
|
346
503
|
for (const slug of slugs) {
|
|
347
504
|
const skill = hubBySlug.get(slug);
|
|
@@ -351,6 +508,198 @@ function linkSkills(options) {
|
|
|
351
508
|
}
|
|
352
509
|
const target = path.join(targetDir, slug);
|
|
353
510
|
const current = classifyTarget(target, hubDir);
|
|
511
|
+
const isFamily = family.has(slug) || (familyFallback && slug.startsWith('yotta-'));
|
|
512
|
+
|
|
513
|
+
// ── 家族技能:链接时唯一性收敛(产品口径) ──────────────────────────
|
|
514
|
+
if (isFamily) {
|
|
515
|
+
const copies = listFamilyCopies(targetDir, slug);
|
|
516
|
+
const exactCopy = copies.find((copy) => copy.name === slug) || null;
|
|
517
|
+
const renamedCopies = copies.filter((copy) => copy.name !== slug);
|
|
518
|
+
const exactHealthy = current.kind === 'link' && current.inHub &&
|
|
519
|
+
current.targetExists !== false && samePath(current.target, skill.dir);
|
|
520
|
+
const moves = [];
|
|
521
|
+
const skipped = [];
|
|
522
|
+
let block = null;
|
|
523
|
+
|
|
524
|
+
const evaluate = (copy, isExact) => {
|
|
525
|
+
if (!copy) return;
|
|
526
|
+
const cls = classifyTarget(copy.dir, hubDir);
|
|
527
|
+
if (cls.kind === 'link') {
|
|
528
|
+
const pointsAtHub = cls.inHub && cls.targetExists !== false && samePath(cls.target, skill.dir);
|
|
529
|
+
if (pointsAtHub && isExact) return; // 已是目标链接,保留
|
|
530
|
+
if (cls.inHub) {
|
|
531
|
+
moves.push({ name: copy.name, dir: copy.dir, version: copy.version || '' });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (isExact) {
|
|
535
|
+
block = { status: 'conflict', note: '目标已存在指向 Hub 之外的链接(默认跳过;确认后可加 --force 替换)' };
|
|
536
|
+
} else {
|
|
537
|
+
skipped.push(copy.name + '(外部链接,保留)');
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (cls.kind === 'directory') {
|
|
542
|
+
const meta = readSkillMeta(copy.dir);
|
|
543
|
+
if (!meta || meta.name !== slug) {
|
|
544
|
+
if (isExact) block = { status: 'conflict', note: '目标已存在真目录(frontmatter name 与技能不符,拒绝收敛)' };
|
|
545
|
+
else skipped.push(copy.name + '(name 不符,保留)');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const version = meta.version || '';
|
|
549
|
+
if (!skill.version) {
|
|
550
|
+
block = { status: 'skipped', note: 'Hub 副本版本无法解析;跳过收敛(fail-safe)' };
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (!version) {
|
|
554
|
+
block = { status: 'skipped', note: copy.name + ' 版本无法解析;跳过收敛(fail-safe)' };
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (compareVersions(version, skill.version) > 0) {
|
|
558
|
+
block = {
|
|
559
|
+
status: 'skipped',
|
|
560
|
+
note: copy.name + ' v' + version + ' 高于 Hub v' + skill.version + ';先运行 yotta-skills hub update 再链接',
|
|
561
|
+
};
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
moves.push({ name: copy.name, dir: copy.dir, version });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (cls.kind === 'file') {
|
|
568
|
+
if (isExact) block = { status: 'conflict', note: '目标已存在普通文件(拒绝覆盖)' };
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
evaluate(exactCopy, true);
|
|
572
|
+
for (const copy of renamedCopies) evaluate(copy, false);
|
|
573
|
+
|
|
574
|
+
if (block) {
|
|
575
|
+
if (exactHealthy) {
|
|
576
|
+
results.push({ slug, status: 'linked', target, note: '已链接到 hub;仍有未收敛副本:' + block.note });
|
|
577
|
+
} else {
|
|
578
|
+
results.push({ slug, status: block.status, target, note: block.note });
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (moves.length === 0 && exactHealthy) {
|
|
583
|
+
results.push({
|
|
584
|
+
slug,
|
|
585
|
+
status: 'linked',
|
|
586
|
+
target,
|
|
587
|
+
note: '已链接到 hub' + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
|
|
588
|
+
});
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (moves.length > 0 && dryRun) {
|
|
592
|
+
results.push({
|
|
593
|
+
slug,
|
|
594
|
+
status: 'would-converge',
|
|
595
|
+
target,
|
|
596
|
+
note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)后建立链接:' +
|
|
597
|
+
moves.map((item) => item.name).join('、'),
|
|
598
|
+
moves: moves.map((item) => ({ from: item.dir, name: item.name, version: item.version || null })),
|
|
599
|
+
trashDir: trashRoot,
|
|
600
|
+
});
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (moves.length === 0 && dryRun) {
|
|
604
|
+
results.push({ slug, status: 'would-link', target, note: '将建立链接' });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (moves.length === 0) {
|
|
608
|
+
try {
|
|
609
|
+
makeLink(skill.dir, target);
|
|
610
|
+
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
611
|
+
const record = {
|
|
612
|
+
slug,
|
|
613
|
+
agent: opts.agentId || null,
|
|
614
|
+
label: opts.label || null,
|
|
615
|
+
dir: targetDir,
|
|
616
|
+
target,
|
|
617
|
+
hubDir: skill.dir,
|
|
618
|
+
linkedAt: nowIso(),
|
|
619
|
+
backup: null,
|
|
620
|
+
};
|
|
621
|
+
if (index >= 0) links[index] = record;
|
|
622
|
+
else links.push(record);
|
|
623
|
+
results.push({
|
|
624
|
+
slug,
|
|
625
|
+
status: 'linked',
|
|
626
|
+
target,
|
|
627
|
+
note: '已建立链接' + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
|
|
628
|
+
});
|
|
629
|
+
appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup: null });
|
|
630
|
+
} catch (error) {
|
|
631
|
+
results.push({
|
|
632
|
+
slug,
|
|
633
|
+
status: 'error',
|
|
634
|
+
target,
|
|
635
|
+
note: error.message + '(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const moved = [];
|
|
641
|
+
try {
|
|
642
|
+
for (const item of moves) {
|
|
643
|
+
const dest = path.join(trashRoot, stamp, hostName, item.name);
|
|
644
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
645
|
+
fs.renameSync(item.dir, dest);
|
|
646
|
+
moved.push({ from: item.dir, to: dest, name: item.name, version: item.version || null });
|
|
647
|
+
}
|
|
648
|
+
if (!exactHealthy) makeLink(skill.dir, target);
|
|
649
|
+
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
650
|
+
const record = {
|
|
651
|
+
slug,
|
|
652
|
+
agent: opts.agentId || null,
|
|
653
|
+
label: opts.label || null,
|
|
654
|
+
dir: targetDir,
|
|
655
|
+
target,
|
|
656
|
+
hubDir: skill.dir,
|
|
657
|
+
linkedAt: nowIso(),
|
|
658
|
+
backup: null,
|
|
659
|
+
converged: moved.map((item) => item.to),
|
|
660
|
+
};
|
|
661
|
+
if (index >= 0) links[index] = record;
|
|
662
|
+
else links.push(record);
|
|
663
|
+
results.push({
|
|
664
|
+
slug,
|
|
665
|
+
status: 'linked',
|
|
666
|
+
target,
|
|
667
|
+
note: '已收敛 ' + moved.length + ' 份旧副本(回收站保留 ' + TRASH_RETENTION_DAYS + ' 天)并建立链接' +
|
|
668
|
+
(skipped.length ? ';保留:' + skipped.join('、') : ''),
|
|
669
|
+
moved,
|
|
670
|
+
trashDir: trashRoot,
|
|
671
|
+
});
|
|
672
|
+
appendAudit(hubDir, {
|
|
673
|
+
event: 'converge',
|
|
674
|
+
slug,
|
|
675
|
+
target,
|
|
676
|
+
hubDir: skill.dir,
|
|
677
|
+
hubVersion: skill.version,
|
|
678
|
+
moved,
|
|
679
|
+
trashDir: trashRoot,
|
|
680
|
+
});
|
|
681
|
+
} catch (error) {
|
|
682
|
+
let restored = true;
|
|
683
|
+
for (const item of moved.slice().reverse()) {
|
|
684
|
+
try {
|
|
685
|
+
fs.mkdirSync(path.dirname(item.from), { recursive: true });
|
|
686
|
+
fs.renameSync(item.to, item.from);
|
|
687
|
+
} catch (_) { restored = false; }
|
|
688
|
+
}
|
|
689
|
+
results.push({
|
|
690
|
+
slug,
|
|
691
|
+
status: 'error',
|
|
692
|
+
target,
|
|
693
|
+
restored,
|
|
694
|
+
note: '收敛失败:' + error.message +
|
|
695
|
+
(restored ? '(已恢复原目录)' : '(恢复失败,请查看回收站:' + trashRoot + ')'),
|
|
696
|
+
trashDir: trashRoot,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── 非家族技能:保持原行为(同名冲突默认跳过,不自动删) ────────────
|
|
354
703
|
let backup = null;
|
|
355
704
|
if (current.kind === 'link' && current.inHub && current.targetExists !== false && samePath(current.target, skill.dir)) {
|
|
356
705
|
results.push({ slug, status: 'linked', target, note: '已链接到 hub' });
|
|
@@ -393,7 +742,7 @@ function linkSkills(options) {
|
|
|
393
742
|
continue;
|
|
394
743
|
}
|
|
395
744
|
try {
|
|
396
|
-
|
|
745
|
+
makeLink(skill.dir, target);
|
|
397
746
|
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
398
747
|
const record = {
|
|
399
748
|
slug,
|
|
@@ -421,10 +770,10 @@ function linkSkills(options) {
|
|
|
421
770
|
if (!dryRun) {
|
|
422
771
|
writeLinkState(hubDir, { links });
|
|
423
772
|
try {
|
|
424
|
-
syncHubState(hubDir, { homeDir: opts.homeDir, env: opts.env });
|
|
773
|
+
syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
|
|
425
774
|
} catch (_) { /* link state is already durable; state refresh is best-effort */ }
|
|
426
775
|
}
|
|
427
|
-
return { hubDir, targetDir, dryRun, results };
|
|
776
|
+
return { hubDir, targetDir, dryRun, results, trashRoot, prunedTrash };
|
|
428
777
|
}
|
|
429
778
|
|
|
430
779
|
function unlinkSkills(options) {
|
|
@@ -514,6 +863,71 @@ function status(options) {
|
|
|
514
863
|
};
|
|
515
864
|
}
|
|
516
865
|
|
|
866
|
+
/**
|
|
867
|
+
* doctor 只读检查:宿主目录中的家族技能副本是否唯一 / 版本是否落后。
|
|
868
|
+
* 只报告不修改;真正的收敛发生在 hub link 执行时。
|
|
869
|
+
*/
|
|
870
|
+
function singleSourceChecks(opts, add) {
|
|
871
|
+
const hubDir = path.resolve(opts.hubDir);
|
|
872
|
+
if (!fs.existsSync(hubDir)) return;
|
|
873
|
+
const family = familySlugSet(opts.manifest);
|
|
874
|
+
let discovery = opts.discovery;
|
|
875
|
+
if (!discovery) {
|
|
876
|
+
try {
|
|
877
|
+
discovery = require('./agent-discovery').discoverHosts({ homeDir: opts.homeDir, env: opts.env });
|
|
878
|
+
} catch (_) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const hubSkills = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
|
|
883
|
+
for (const host of discovery.hosts || []) {
|
|
884
|
+
if (!host || !host.exists) continue;
|
|
885
|
+
const groups = new Map();
|
|
886
|
+
let entries;
|
|
887
|
+
try {
|
|
888
|
+
entries = fs.readdirSync(host.dir, { withFileTypes: true });
|
|
889
|
+
} catch (_) {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
for (const entry of entries) {
|
|
893
|
+
if (entry.name.startsWith('.')) continue;
|
|
894
|
+
if (!entry.name.startsWith('yotta-')) continue; // 家族 slug 统一前缀,避免读外部技能
|
|
895
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
896
|
+
const dir = path.join(host.dir, entry.name);
|
|
897
|
+
const meta = readSkillMeta(dir);
|
|
898
|
+
if (!meta || !meta.name || !family.has(meta.name)) continue;
|
|
899
|
+
if (!groups.has(meta.name)) groups.set(meta.name, []);
|
|
900
|
+
groups.get(meta.name).push({
|
|
901
|
+
name: entry.name,
|
|
902
|
+
dir,
|
|
903
|
+
version: meta.version || '',
|
|
904
|
+
kind: entry.isSymbolicLink() ? 'link' : 'directory',
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
for (const [slug, copies] of groups) {
|
|
908
|
+
if (copies.length > 1) {
|
|
909
|
+
add('single_source:' + (host.agentId || host.label) + ':' + slug, false, 'warning',
|
|
910
|
+
host.label + ' 中 ' + slug + ' 存在 ' + copies.length + ' 份副本(按 name 引用有歧义):' +
|
|
911
|
+
copies.map((copy) => copy.name).join('、'),
|
|
912
|
+
'运行 yotta-skills hub link --agent ' + (host.agentId || '<id>') + ' 收敛(旧副本移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)');
|
|
913
|
+
}
|
|
914
|
+
const hubSkill = hubSkills.get(slug);
|
|
915
|
+
if (!hubSkill || !hubSkill.version) continue;
|
|
916
|
+
const single = copies.length === 1 ? copies[0] : null;
|
|
917
|
+
if (!single || single.kind !== 'directory' || !single.version) continue;
|
|
918
|
+
const diff = compareVersions(single.version, hubSkill.version);
|
|
919
|
+
if (diff > 0) {
|
|
920
|
+
add('single_source_version:' + (host.agentId || host.label) + ':' + slug, false, 'warning',
|
|
921
|
+
host.label + ' 的 ' + slug + ' v' + single.version + ' 高于 Hub v' + hubSkill.version,
|
|
922
|
+
'先运行 yotta-skills hub update(或该技能自身更新)再链接');
|
|
923
|
+
} else if (diff < 0) {
|
|
924
|
+
add('single_source_version:' + (host.agentId || host.label) + ':' + slug, true, 'info',
|
|
925
|
+
host.label + ' 的 ' + slug + ' v' + single.version + ' 低于 Hub v' + hubSkill.version + '(链接时自动收敛)');
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
517
931
|
function doctor(options) {
|
|
518
932
|
const opts = options || {};
|
|
519
933
|
const hubDir = path.resolve(opts.hubDir);
|
|
@@ -562,6 +976,10 @@ function doctor(options) {
|
|
|
562
976
|
link.status === 'ok' ? null : '运行 hub link --all 重建,或 hub unlink 清理断链');
|
|
563
977
|
}
|
|
564
978
|
|
|
979
|
+
try {
|
|
980
|
+
singleSourceChecks(opts, add);
|
|
981
|
+
} catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
|
|
982
|
+
|
|
565
983
|
const errors = checks.filter((check) => !check.ok && check.severity === 'error');
|
|
566
984
|
const warnings = checks.filter((check) => !check.ok && check.severity === 'warning');
|
|
567
985
|
return {
|
|
@@ -581,6 +999,8 @@ module.exports = {
|
|
|
581
999
|
STATE_FILE,
|
|
582
1000
|
LINKS_FILE,
|
|
583
1001
|
AUDIT_FILE,
|
|
1002
|
+
TRASH_RETENTION_DAYS,
|
|
1003
|
+
HUB_FAMILY_EXTRAS,
|
|
584
1004
|
resolveHubDir,
|
|
585
1005
|
hubPaths,
|
|
586
1006
|
readHubState,
|
|
@@ -591,6 +1011,12 @@ module.exports = {
|
|
|
591
1011
|
hashTree,
|
|
592
1012
|
scanHubSkills,
|
|
593
1013
|
syncHubState,
|
|
1014
|
+
familySlugSet,
|
|
1015
|
+
compareVersions,
|
|
1016
|
+
readSkillMeta,
|
|
1017
|
+
resolveTrashRoot,
|
|
1018
|
+
pruneTrash,
|
|
1019
|
+
listFamilyCopies,
|
|
594
1020
|
createDirLink,
|
|
595
1021
|
removeDirLink,
|
|
596
1022
|
classifyTarget,
|
|
@@ -277,6 +277,13 @@ async function fetchPackument(pkg, opts) {
|
|
|
277
277
|
/** pin=true 取精确版本;pin=false 过滤同 major 取最高正式版本。 */
|
|
278
278
|
function resolveVersion(packument, skill, pin) {
|
|
279
279
|
const versions = (packument && packument.versions) || {};
|
|
280
|
+
if (pin && String(skill.version) === 'latest') {
|
|
281
|
+
const latest = (packument && packument['dist-tags'] && packument['dist-tags'].latest) || null;
|
|
282
|
+
if (!latest || !versions[latest]) {
|
|
283
|
+
return { error: 'registry 无 dist-tags.latest(' + skill.pkg + ')' };
|
|
284
|
+
}
|
|
285
|
+
return { version: String(latest), dist: versions[latest].dist || {} };
|
|
286
|
+
}
|
|
280
287
|
if (pin) {
|
|
281
288
|
const exact = versions[String(skill.version)];
|
|
282
289
|
if (!exact) {
|
|
@@ -125,6 +125,44 @@ function selectRepresentativeVariant(variants) {
|
|
|
125
125
|
return selected || variants.find((variant) => variant.version) || variants[0] || null;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* 严格 YAML frontmatter 只读启发式告警(不解析、不修改源文件)。
|
|
130
|
+
*
|
|
131
|
+
* 命中常见「宽松解析可用、严格解析器拒绝」的写法:
|
|
132
|
+
* - 未加引号的单行值以逗号开头(如 `tags: ,development`)→ Plain value cannot start with flow indicator;
|
|
133
|
+
* - 未加引号的单行值包含 `: `(如折叠后的 description)→ mapping values are not allowed here。
|
|
134
|
+
* 仅供展示与记录;不影响使用则不处理(非元技能规则)。
|
|
135
|
+
*/
|
|
136
|
+
function strictFrontmatterWarnings(text) {
|
|
137
|
+
const m = String(text || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
138
|
+
if (!m) return [];
|
|
139
|
+
const warnings = [];
|
|
140
|
+
let blockKey = null;
|
|
141
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
142
|
+
if (blockKey) {
|
|
143
|
+
if (!raw.trim() || /^\s/.test(raw)) continue;
|
|
144
|
+
blockKey = null;
|
|
145
|
+
}
|
|
146
|
+
const t = raw.trim();
|
|
147
|
+
if (!t || t.startsWith('#') || t.startsWith('-')) continue;
|
|
148
|
+
const idx = t.indexOf(':');
|
|
149
|
+
if (idx <= 0) continue;
|
|
150
|
+
const key = t.slice(0, idx).trim();
|
|
151
|
+
const val = t.slice(idx + 1).trim();
|
|
152
|
+
if (!val) continue;
|
|
153
|
+
if (/^[>|][-+]?$/.test(val)) { blockKey = key; continue; }
|
|
154
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) continue;
|
|
155
|
+
if (val.startsWith(',')) {
|
|
156
|
+
warnings.push(key + ' 值以逗号开头,严格 YAML 解析器会拒绝(Plain value cannot start with flow indicator character ,)');
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!val.startsWith('{') && !val.startsWith('[') && val.includes(': ')) {
|
|
160
|
+
warnings.push(key + ' 值包含「: 」且未加引号,严格 YAML 解析器会拒绝(mapping values are not allowed here)');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return warnings;
|
|
164
|
+
}
|
|
165
|
+
|
|
128
166
|
/** 扫描单个技能目录:子目录含 SKILL.md 且 frontmatter 有 name 即视为技能。 */
|
|
129
167
|
function scanSkillDir(dir) {
|
|
130
168
|
const results = [];
|
|
@@ -396,6 +434,9 @@ function formatInventory(registry, filePath) {
|
|
|
396
434
|
module.exports = {
|
|
397
435
|
AGENT_DIRS,
|
|
398
436
|
parseFrontmatter,
|
|
437
|
+
parseSemver,
|
|
438
|
+
compareSemver,
|
|
439
|
+
strictFrontmatterWarnings,
|
|
399
440
|
scanSkillDir,
|
|
400
441
|
defaultRoots,
|
|
401
442
|
scanRoots,
|
|
@@ -11,8 +11,24 @@ symlink),升级只改真源,已链接宿主即时生效。
|
|
|
11
11
|
- Hub 真源:`~/.yottaskills/hub`
|
|
12
12
|
- 技能台账:`<hub>/.yotta-hub.json`
|
|
13
13
|
- 链接台账:`<hub>/.yotta-links.json`
|
|
14
|
+
- 收敛回收站:`~/.yottaskills/trash/<时间戳>/<宿主>/<技能>/`(保留 7 天,可用 `YOTTA_SKILLS_TRASH` 覆盖)
|
|
14
15
|
- 覆盖方式:`--hub <dir>` 或 `YOTTA_SKILLS_HUB`
|
|
15
16
|
|
|
17
|
+
## 元技能唯一性收敛(`hub link` 执行时)
|
|
18
|
+
|
|
19
|
+
元技能(清单 27 + 特殊家族 5:元开 / 元伴 / 元呈 / 元阁本体 / 元信MCP)在
|
|
20
|
+
`hub link`(含面板链接动作)执行时收敛唯一性,不做事后全机扫描:
|
|
21
|
+
|
|
22
|
+
1. 收集宿主目录中的同名真目录与 `yotta-X__*` 重命名副本(frontmatter `name` 相同);
|
|
23
|
+
2. 版本闸门:Hub 版本 ≥ 宿主副本版本才收敛;宿主更高 → 跳过并提示先 `hub update`;
|
|
24
|
+
版本无法解析 → 跳过提示(fail-safe);
|
|
25
|
+
3. 旧副本移入回收站(保留 7 天、输出恢复路径)→ 建立指向 Hub 的链接;
|
|
26
|
+
4. 先移后链;建链失败自动尝试恢复已移动副本,恢复失败时报出回收站路径;
|
|
27
|
+
5. 外部技能保持原行为:同名冲突默认跳过,不自动删、不自动更新。
|
|
28
|
+
|
|
29
|
+
落地后跑一次 `hub link --all` 即可完成现存收敛。`hub doctor` 的
|
|
30
|
+
`single_source:*` 检查只读报告宿主中的多份副本与版本参差,不自动修改。
|
|
31
|
+
|
|
16
32
|
## 命令
|
|
17
33
|
|
|
18
34
|
```bash
|
|
@@ -20,17 +36,22 @@ symlink),升级只改真源,已链接宿主即时生效。
|
|
|
20
36
|
npx -y @yottameta/yotta-skills hub hosts
|
|
21
37
|
|
|
22
38
|
# 安装 / 更新 Hub 真源
|
|
39
|
+
# 范围 = 全家清单 27 + 特殊家族 5;特殊家族跟随各自 npm latest
|
|
40
|
+
# 非元技能不参与更新(来源无统一安装源,用户自行处理)
|
|
23
41
|
npx -y @yottameta/yotta-skills hub install
|
|
24
42
|
npx -y @yottameta/yotta-skills hub update
|
|
25
43
|
|
|
26
44
|
# 接管各宿主现有技能:先只读预演,再复制收编(原目录保留)
|
|
45
|
+
# --apply 输出明示范围与目标 Hub;--skip-scan 会显式提示跳过装前扫描
|
|
46
|
+
# 非元技能 frontmatter 的严格 YAML 风险只读告警,不影响使用则不处理
|
|
27
47
|
npx -y @yottameta/yotta-skills hub adopt --scan
|
|
28
48
|
npx -y @yottameta/yotta-skills hub adopt --apply
|
|
29
49
|
|
|
30
50
|
# 非元阁来源技能没有统一更新源:手动从指定目录刷新
|
|
31
51
|
npx -y @yottameta/yotta-skills hub refresh my-skill --from <技能目录>
|
|
32
52
|
|
|
33
|
-
#
|
|
53
|
+
# 分发到全部已发现宿主:元技能链接时收敛旧副本(回收站 7 天);
|
|
54
|
+
# 外部技能同名真目录默认跳过(--force 才备份并替换)
|
|
34
55
|
npx -y @yottameta/yotta-skills hub link --all
|
|
35
56
|
|
|
36
57
|
# 只删除链接,不动 Hub 真源
|
|
@@ -79,4 +100,6 @@ install / update / refresh 只在高级 CLI 页给出可复制命令,不在网
|
|
|
79
100
|
|
|
80
101
|
- `unlink` 只删除 lstat 确认为链接、且 readlink 目标位于 Hub 内的路径。
|
|
81
102
|
- 真目录 / 非 Hub 链接一律拒绝删除;`--force` 对真目录先备份再替换。
|
|
103
|
+
- 收敛只动元技能在宿主目录中的旧副本:移入回收站(7 天)而非直删;
|
|
104
|
+
不碰插件与宿主 MCP 配置;外部技能源文件绝不修改。
|
|
82
105
|
- Hub 仅本机使用,不对外分发技能内容。
|