@yottameta/yotta-skills-plugin 0.25.1 → 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 +19 -8
- package/skills/yotta-skills/assets/view.html +738 -0
- package/skills/yotta-skills/bin/yotta-skills.js +143 -55
- package/skills/yotta-skills/lib/agent-dirs.js +1 -1
- package/skills/yotta-skills/lib/hub-adopt.js +16 -0
- package/skills/yotta-skills/lib/hub-scan.js +97 -0
- package/skills/yotta-skills/lib/hub-view-server.js +680 -0
- package/skills/yotta-skills/lib/hub.js +454 -9
- package/skills/yotta-skills/lib/install-snapshot.js +20 -6
- package/skills/yotta-skills/lib/registry-fetch.js +7 -0
- package/skills/yotta-skills/lib/skills-scan.js +42 -1
- package/skills/yotta-skills/references/hub.md +40 -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();
|
|
@@ -300,11 +445,30 @@ function classifyTarget(target, hubDir) {
|
|
|
300
445
|
return { kind: 'missing' };
|
|
301
446
|
}
|
|
302
447
|
if (stat.isSymbolicLink()) {
|
|
303
|
-
|
|
448
|
+
let real = null;
|
|
449
|
+
let targetExists = false;
|
|
450
|
+
try {
|
|
451
|
+
real = fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target);
|
|
452
|
+
targetExists = true;
|
|
453
|
+
} catch (_) {
|
|
454
|
+
try {
|
|
455
|
+
real = fs.realpathSync(target);
|
|
456
|
+
targetExists = true;
|
|
457
|
+
} catch (_) { /* broken link: fall back to the recorded link value */ }
|
|
458
|
+
}
|
|
459
|
+
if (!real) {
|
|
460
|
+
// A broken junction / symlink no longer resolves. Read the link value so
|
|
461
|
+
// an unlink can still tell whether it points into the hub (fail-closed
|
|
462
|
+
// cleanup path) without treating it as an arbitrary directory.
|
|
463
|
+
let linkValue = null;
|
|
464
|
+
try { linkValue = fs.readlinkSync(target); } catch (_) { linkValue = null; }
|
|
465
|
+
real = linkValue ? path.resolve(path.dirname(path.resolve(target)), linkValue) : path.resolve(target);
|
|
466
|
+
}
|
|
304
467
|
return {
|
|
305
468
|
kind: 'link',
|
|
306
469
|
target: real,
|
|
307
470
|
inHub: isInside(hubDir, real),
|
|
471
|
+
targetExists,
|
|
308
472
|
};
|
|
309
473
|
}
|
|
310
474
|
if (stat.isDirectory()) return { kind: 'directory' };
|
|
@@ -321,8 +485,20 @@ function linkSkills(options) {
|
|
|
321
485
|
? opts.slugs
|
|
322
486
|
: scanHubSkills(hubDir).map((item) => item.slug);
|
|
323
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;
|
|
324
490
|
const links = readLinkState(hubDir).links.slice();
|
|
325
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
|
+
}
|
|
326
502
|
|
|
327
503
|
for (const slug of slugs) {
|
|
328
504
|
const skill = hubBySlug.get(slug);
|
|
@@ -332,8 +508,200 @@ function linkSkills(options) {
|
|
|
332
508
|
}
|
|
333
509
|
const target = path.join(targetDir, slug);
|
|
334
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
|
+
// ── 非家族技能:保持原行为(同名冲突默认跳过,不自动删) ────────────
|
|
335
703
|
let backup = null;
|
|
336
|
-
if (current.kind === 'link' && current.inHub && samePath(current.target, skill.dir)) {
|
|
704
|
+
if (current.kind === 'link' && current.inHub && current.targetExists !== false && samePath(current.target, skill.dir)) {
|
|
337
705
|
results.push({ slug, status: 'linked', target, note: '已链接到 hub' });
|
|
338
706
|
continue;
|
|
339
707
|
}
|
|
@@ -374,7 +742,7 @@ function linkSkills(options) {
|
|
|
374
742
|
continue;
|
|
375
743
|
}
|
|
376
744
|
try {
|
|
377
|
-
|
|
745
|
+
makeLink(skill.dir, target);
|
|
378
746
|
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
379
747
|
const record = {
|
|
380
748
|
slug,
|
|
@@ -402,10 +770,10 @@ function linkSkills(options) {
|
|
|
402
770
|
if (!dryRun) {
|
|
403
771
|
writeLinkState(hubDir, { links });
|
|
404
772
|
try {
|
|
405
|
-
syncHubState(hubDir, { homeDir: opts.homeDir, env: opts.env });
|
|
773
|
+
syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
|
|
406
774
|
} catch (_) { /* link state is already durable; state refresh is best-effort */ }
|
|
407
775
|
}
|
|
408
|
-
return { hubDir, targetDir, dryRun, results };
|
|
776
|
+
return { hubDir, targetDir, dryRun, results, trashRoot, prunedTrash };
|
|
409
777
|
}
|
|
410
778
|
|
|
411
779
|
function unlinkSkills(options) {
|
|
@@ -462,9 +830,9 @@ function linkStatus(hubDir) {
|
|
|
462
830
|
const current = classifyTarget(item.target, hubDir);
|
|
463
831
|
return {
|
|
464
832
|
...item,
|
|
465
|
-
status: current.kind === 'link'
|
|
466
|
-
|
|
467
|
-
|
|
833
|
+
status: current.kind === 'link'
|
|
834
|
+
? (!current.inHub ? 'drift' : (current.targetExists === false ? 'broken' : 'ok'))
|
|
835
|
+
: current.kind === 'missing' ? 'broken' : 'drift',
|
|
468
836
|
actual: current,
|
|
469
837
|
};
|
|
470
838
|
});
|
|
@@ -495,6 +863,71 @@ function status(options) {
|
|
|
495
863
|
};
|
|
496
864
|
}
|
|
497
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
|
+
|
|
498
931
|
function doctor(options) {
|
|
499
932
|
const opts = options || {};
|
|
500
933
|
const hubDir = path.resolve(opts.hubDir);
|
|
@@ -543,6 +976,10 @@ function doctor(options) {
|
|
|
543
976
|
link.status === 'ok' ? null : '运行 hub link --all 重建,或 hub unlink 清理断链');
|
|
544
977
|
}
|
|
545
978
|
|
|
979
|
+
try {
|
|
980
|
+
singleSourceChecks(opts, add);
|
|
981
|
+
} catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
|
|
982
|
+
|
|
546
983
|
const errors = checks.filter((check) => !check.ok && check.severity === 'error');
|
|
547
984
|
const warnings = checks.filter((check) => !check.ok && check.severity === 'warning');
|
|
548
985
|
return {
|
|
@@ -562,6 +999,8 @@ module.exports = {
|
|
|
562
999
|
STATE_FILE,
|
|
563
1000
|
LINKS_FILE,
|
|
564
1001
|
AUDIT_FILE,
|
|
1002
|
+
TRASH_RETENTION_DAYS,
|
|
1003
|
+
HUB_FAMILY_EXTRAS,
|
|
565
1004
|
resolveHubDir,
|
|
566
1005
|
hubPaths,
|
|
567
1006
|
readHubState,
|
|
@@ -572,6 +1011,12 @@ module.exports = {
|
|
|
572
1011
|
hashTree,
|
|
573
1012
|
scanHubSkills,
|
|
574
1013
|
syncHubState,
|
|
1014
|
+
familySlugSet,
|
|
1015
|
+
compareVersions,
|
|
1016
|
+
readSkillMeta,
|
|
1017
|
+
resolveTrashRoot,
|
|
1018
|
+
pruneTrash,
|
|
1019
|
+
listFamilyCopies,
|
|
575
1020
|
createDirLink,
|
|
576
1021
|
removeDirLink,
|
|
577
1022
|
classifyTarget,
|
|
@@ -80,10 +80,10 @@ function readVersion(root) {
|
|
|
80
80
|
function validateSnapshot(snapshot) {
|
|
81
81
|
const root = path.resolve(snapshot || '');
|
|
82
82
|
if (!snapshot || !fs.existsSync(root)) {
|
|
83
|
-
return { ok: false, version: null, digest: null, legacy: false, reason: '快照目录不存在' };
|
|
83
|
+
return { ok: false, version: null, digest: null, legacy: false, reason: '快照目录不存在', source: null, createdAt: null };
|
|
84
84
|
}
|
|
85
85
|
if (!fs.statSync(root).isDirectory()) {
|
|
86
|
-
return { ok: false, version: null, digest: null, legacy: false, reason: '快照不是目录' };
|
|
86
|
+
return { ok: false, version: null, digest: null, legacy: false, reason: '快照不是目录', source: null, createdAt: null };
|
|
87
87
|
}
|
|
88
88
|
const metaFile = root + '.meta.json';
|
|
89
89
|
if (fs.existsSync(metaFile)) {
|
|
@@ -91,13 +91,21 @@ function validateSnapshot(snapshot) {
|
|
|
91
91
|
try {
|
|
92
92
|
metadata = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
93
93
|
} catch (error) {
|
|
94
|
-
return { ok: false, version: null, digest: null, legacy: false, reason: '快照元数据无法解析: ' + error.message };
|
|
94
|
+
return { ok: false, version: null, digest: null, legacy: false, reason: '快照元数据无法解析: ' + error.message, source: null, createdAt: null };
|
|
95
95
|
}
|
|
96
96
|
let current;
|
|
97
97
|
try {
|
|
98
98
|
current = computeTreeDigest(root);
|
|
99
99
|
} catch (error) {
|
|
100
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
version: metadata.version || null,
|
|
103
|
+
digest: metadata.digest || null,
|
|
104
|
+
legacy: false,
|
|
105
|
+
reason: '快照不可读: ' + error.message,
|
|
106
|
+
source: metadata.source || null,
|
|
107
|
+
createdAt: metadata.created_at || null,
|
|
108
|
+
};
|
|
101
109
|
}
|
|
102
110
|
if (!metadata.digest || current.digest !== metadata.digest) {
|
|
103
111
|
return {
|
|
@@ -106,6 +114,8 @@ function validateSnapshot(snapshot) {
|
|
|
106
114
|
digest: metadata.digest || null,
|
|
107
115
|
legacy: false,
|
|
108
116
|
reason: '快照摘要不一致,可能已被修改',
|
|
117
|
+
source: metadata.source || null,
|
|
118
|
+
createdAt: metadata.created_at || null,
|
|
109
119
|
};
|
|
110
120
|
}
|
|
111
121
|
return {
|
|
@@ -114,14 +124,16 @@ function validateSnapshot(snapshot) {
|
|
|
114
124
|
digest: metadata.digest,
|
|
115
125
|
legacy: false,
|
|
116
126
|
reason: null,
|
|
127
|
+
source: metadata.source || null,
|
|
128
|
+
createdAt: metadata.created_at || null,
|
|
117
129
|
};
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
const version = readVersion(root);
|
|
121
133
|
if (!fs.existsSync(path.join(root, 'SKILL.md'))) {
|
|
122
|
-
return { ok: false, version, digest: null, legacy: true, reason: '旧快照缺少 SKILL.md' };
|
|
134
|
+
return { ok: false, version, digest: null, legacy: true, reason: '旧快照缺少 SKILL.md', source: null, createdAt: null };
|
|
123
135
|
}
|
|
124
|
-
return { ok: true, version, digest: null, legacy: true, reason: null };
|
|
136
|
+
return { ok: true, version, digest: null, legacy: true, reason: null, source: null, createdAt: null };
|
|
125
137
|
}
|
|
126
138
|
|
|
127
139
|
function createSnapshot(target, options) {
|
|
@@ -191,6 +203,8 @@ function listSnapshots(homeDir, slug) {
|
|
|
191
203
|
version: validation.version,
|
|
192
204
|
legacy: validation.legacy,
|
|
193
205
|
reason: validation.reason,
|
|
206
|
+
source: validation.source || null,
|
|
207
|
+
createdAt: validation.createdAt || null,
|
|
194
208
|
mtime_ms: mtimeMs,
|
|
195
209
|
});
|
|
196
210
|
}
|
|
@@ -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) {
|