@yottameta/yotta-skills-plugin 0.28.3 → 0.29.1

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.
@@ -87,7 +87,9 @@ const AGENT_DIRS = {
87
87
  pochi: { label: 'Pochi', dirs: ['.pochi/skills'] },
88
88
  adal: { label: 'AdaL', dirs: ['.adal/skills'] },
89
89
  dsh: { label: 'DSH / DeepSeek Harness', dirs: ['.dsh/skills'] },
90
- yottacode: { label: 'YottaCode', dirs: ['.yottacode/skills'], verified: false },
90
+ // YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),
91
+ // 不纳入元阁接管:neverLink 表示发现 / 显示 / 链接全部跳过(0.29.1 U2)。
92
+ yottacode: { label: 'YottaCode', dirs: ['.yottacode/skills'], verified: false, neverLink: true },
91
93
  box: { label: 'Box Agent', dirs: ['.box-agent/skills'], verified: false },
92
94
  lmstudio: { label: 'LM Studio', dirs: ['.lmstudio/skills'] },
93
95
  ccswitch: { label: 'CC Switch', dirs: ['.cc-switch/skills'] },
@@ -167,6 +169,14 @@ function resolveUserDir(rel, options) {
167
169
  return path.join(home, rel);
168
170
  }
169
171
 
172
+ const YOTTACODE_PATH_RE = /(^|[\\/])\.yottacode([\\/]|$)/i;
173
+
174
+ /** YottaCode 路径判定(不接管红线;发现 / 链接 / 注册 / 标记统一 fail-closed)。 */
175
+ function isYottaCodeDir(dir) {
176
+ if (!dir) return false;
177
+ return YOTTACODE_PATH_RE.test(path.resolve(dir));
178
+ }
179
+
170
180
  /** All known agent skill directories, including missing ones. */
171
181
  function knownRoots(options) {
172
182
  const roots = [];
@@ -179,8 +189,11 @@ function knownRoots(options) {
179
189
  roots.push({ dir: resolved, agentId, label, known: true, verified: verified !== false });
180
190
  };
181
191
  for (const [agentId, info] of Object.entries(AGENT_DIRS)) {
192
+ if (info.neverLink) continue;
182
193
  for (const rel of info.dirs) {
183
- add(resolveUserDir(rel, options), agentId, info.label, info.verified !== false);
194
+ const dir = resolveUserDir(rel, options);
195
+ if (isYottaCodeDir(dir)) continue;
196
+ add(dir, agentId, info.label, info.verified !== false);
184
197
  }
185
198
  }
186
199
  return roots;
@@ -213,7 +226,7 @@ function envRoots(options) {
213
226
  add(envPath(env, 'DSH_AGENTS_HOME') && path.join(env.DSH_AGENTS_HOME, 'skills'), 'dsh', 'DSH Agents(DSH_AGENTS_HOME)');
214
227
  add(envPath(env, 'OPENCLAW_STATE_DIR') && path.join(env.OPENCLAW_STATE_DIR, 'skills'), 'openclaw', 'OpenClaw(OPENCLAW_STATE_DIR)');
215
228
  add(envPath(env, 'CLAUDE_CONFIG_DIR') && path.join(env.CLAUDE_CONFIG_DIR, 'skills'), 'claude', 'Claude Code(CLAUDE_CONFIG_DIR)');
216
- add(envPath(env, 'YOTTACODE_HOME') && path.join(env.YOTTACODE_HOME, 'skills'), 'yottacode', 'YottaCode(YOTTACODE_HOME)');
229
+ // YOTTACODE_HOME 不纳入元阁接管(YottaCode 自带三层技能管理,0.29.1 U2)。
217
230
 
218
231
  const extra = String(env.YOTTA_SKILLS_DISCOVERY_ROOTS || '')
219
232
  .split(path.delimiter)
@@ -255,4 +268,5 @@ module.exports = {
255
268
  envRoots,
256
269
  bridgeOnlyDirs,
257
270
  isBridgeOnlyDir,
271
+ isYottaCodeDir,
258
272
  };
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const os = require('os');
16
16
  const path = require('path');
17
17
  const agentDirs = require('./agent-dirs');
18
+ const hostsRegistry = require('./hosts-registry');
18
19
 
19
20
  const SKILL_FILE = 'SKILL.md';
20
21
  const SKIP_DIRS = new Set([
@@ -81,6 +82,7 @@ function isNoisePath(dir, baseDir) {
81
82
  if (part === 'temp' || part === 'tmp') return true;
82
83
  if (/\.bak$/.test(part)) return true;
83
84
  if (part === 'plugin-sources' || part === 'plugins' || part === 'node_modules') return true;
85
+ if (part === '.yottacode') return true;
84
86
  if (part === 'candidate' || part.startsWith('candidate-') || part.includes('staging')) return true;
85
87
  if (part === 'memories' || part === 'connectors' || part === 'workspace') return true;
86
88
  }
@@ -103,6 +105,15 @@ function addRoot(list, seen, dir, meta) {
103
105
  if (meta && meta.label && existing.label === '自动发现目录') existing.label = meta.label;
104
106
  if (meta && meta.verified) existing.verified = true;
105
107
  if (meta && meta.bridgeOnly) existing.bridgeOnly = true;
108
+ // 用户注册优先:覆盖分类与手动标记(0.29.0 F2/F3)。
109
+ if (meta && meta.registry) {
110
+ existing.registry = true;
111
+ existing.verified = true;
112
+ existing.detection = 'user-registry';
113
+ if (meta.label) existing.label = meta.label;
114
+ if (meta.agentId) existing.agentId = meta.agentId;
115
+ if (meta.manualState) existing.manualState = meta.manualState;
116
+ }
106
117
  return;
107
118
  }
108
119
  const record = {
@@ -115,6 +126,8 @@ function addRoot(list, seen, dir, meta) {
115
126
  bridgeOnly: Boolean(meta && meta.bridgeOnly),
116
127
  detection: (meta && meta.detection) || 'mapping',
117
128
  exists: isDirectory(resolved),
129
+ registry: Boolean(meta && meta.registry),
130
+ manualState: (meta && meta.manualState) || null,
118
131
  };
119
132
  record.skillCount = record.exists ? countSkills(resolved) : 0;
120
133
  record.status = record.exists ? 'skills-dir' : 'missing';
@@ -180,7 +193,7 @@ function commonRoots(options) {
180
193
  add(env.OPENCLAW_STATE_DIR, 2);
181
194
  add(env.CLAUDE_CONFIG_DIR, 2);
182
195
  add(env.CODEX_HOME, 2);
183
- add(env.YOTTACODE_HOME, 2);
196
+ // YOTTACODE_HOME 不扫描(YottaCode 自带三层技能管理,0.29.1 U2)。
184
197
 
185
198
  // A portable agent install often lives beside the current workspace. The
186
199
  // scanner only follows agent-looking branches, so this stays bounded.
@@ -223,23 +236,87 @@ function installedMarkers(options) {
223
236
  env['ProgramFiles(x86)'],
224
237
  ];
225
238
  for (const root of roots) collectMarkerNames(root, 2, names, 800);
226
- return [...names].filter((name) => AGENT_HINT.test(name) && !INSTALLED_NOISE.test(name));
239
+ // 标记过滤:已收录宿主的 label 双向匹配 + 启发式关键词;排除卸载器 / 运行时噪音。
240
+ return [...names].filter((name) =>
241
+ (knownLabelMatches(name) || AGENT_HINT.test(name)) && !INSTALLED_NOISE.test(name));
242
+ }
243
+
244
+ function normalizeLabel(value) {
245
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
227
246
  }
228
247
 
248
+ /** 标记名与已收录宿主 label 的双向词边界包含("Trae" ↔ "Trae Code CLI")。 */
229
249
  function knownLabelMatches(name) {
230
- const normalized = String(name || '').toLowerCase();
250
+ const normalized = normalizeLabel(name);
251
+ if (!normalized) return false;
231
252
  for (const info of Object.values(agentDirs.AGENT_DIRS)) {
232
- if (normalized.includes(String(info.label).toLowerCase())) return true;
253
+ const label = normalizeLabel(info.label);
254
+ if (!label) continue;
255
+ if (normalized === label) return true;
256
+ if (normalized.startsWith(label + ' ') || normalized.endsWith(' ' + label) || normalized.includes(' ' + label + ' ')) return true;
257
+ if (label.startsWith(normalized + ' ') || label.endsWith(' ' + normalized) || label.includes(' ' + normalized + ' ')) return true;
233
258
  }
234
259
  return false;
235
260
  }
236
261
 
262
+ /**
263
+ * 应用标记与宿主匹配:label / agentId 与标记名按词边界双向包含。
264
+ * 例:"Claude" 匹配 "Claude Code";"Trae" 匹配 "Trae Code CLI";
265
+ * 避免 "Pi" 误配 "Pilot" 这类裸子串。
266
+ */
267
+ function markerMatchesHost(markerName, host) {
268
+ const marker = normalizeLabel(markerName);
269
+ if (!marker) return false;
270
+ const candidates = [host && host.label, host && host.agentId].filter(Boolean).map(normalizeLabel);
271
+ for (const candidate of candidates) {
272
+ if (!candidate) continue;
273
+ if (marker === candidate) return true;
274
+ if (marker.startsWith(candidate + ' ') || marker.endsWith(' ' + candidate) || marker.includes(' ' + candidate + ' ')) return true;
275
+ if (candidate.startsWith(marker + ' ') || candidate.endsWith(' ' + marker) || candidate.includes(' ' + marker + ' ')) return true;
276
+ }
277
+ return false;
278
+ }
279
+
280
+ /** F3 状态判定:目录 × 实体证据(应用标记 / 用户注册 / 手动标记)。 */
281
+ function judgeHostState(host) {
282
+ if (host.manualState) return host.manualState; // 手动覆盖(available / orphan / ignored)
283
+ const entity = Boolean(host.markerEvidence || (host.evidence && host.evidence.registry));
284
+ if (host.exists && entity) return 'available';
285
+ if (host.exists) return 'orphan';
286
+ if (entity) return 'marker-only';
287
+ return 'missing';
288
+ }
289
+
290
+ /**
291
+ * 0.29.1 U2:installed 配对优先级 ——
292
+ * user-registry / 目录覆盖 / 已核实映射 > env > 未核实映射 > 自动发现。
293
+ */
294
+ function hostPairingPriority(host) {
295
+ if (host.detection === 'user-registry' || host.registry) return 0;
296
+ if (host.detection === 'override') return 1;
297
+ if (host.verified && host.detection === 'mapping') return 1;
298
+ if (host.detection === 'env') return 2;
299
+ if (host.detection === 'mapping') return 3;
300
+ return 4;
301
+ }
302
+
303
+ /** 同级按技能数降序 + 路径稳定排序(消除 last-write-wins)。 */
304
+ function compareHostCandidates(a, b) {
305
+ const priority = hostPairingPriority(a) - hostPairingPriority(b);
306
+ if (priority) return priority;
307
+ const count = (b.skillCount || 0) - (a.skillCount || 0);
308
+ if (count) return count;
309
+ return String(a.dir).localeCompare(String(b.dir));
310
+ }
311
+
237
312
  /**
238
313
  * Discover hosts and their skill directories.
239
314
  *
240
315
  * Returns:
241
316
  * hosts - all candidate skill roots (existing and missing known mappings)
242
- * installed - installed app markers, including hosts without a skills dir
317
+ * installed - entity evidence (app markers + user registry), including hosts
318
+ * without a skills dir; plain directory existence is NOT an
319
+ * installed marker (0.29.0 F3)
243
320
  * errors - non-fatal discovery errors
244
321
  */
245
322
  function discoverHosts(options) {
@@ -247,17 +324,74 @@ function discoverHosts(options) {
247
324
  const seen = new Map();
248
325
  const errors = [];
249
326
 
327
+ // 0.29.1 U2:用户级「不接管」名单 + 目录覆盖(hosts.json;旧文件向后兼容)。
328
+ let registry = { hosts: [], excluded: [], overrides: [] };
329
+ try {
330
+ registry = hostsRegistry.readHostsRegistry(options);
331
+ } catch (_) { /* registry 读取失败不影响发现 */ }
332
+ const overridesByAgent = new Map(
333
+ (registry.overrides || []).map((item) => [String(item.agentId).toLowerCase(), item]),
334
+ );
335
+ const previousDirKeys = new Set();
336
+ for (const item of registry.overrides || []) {
337
+ for (const prev of item.previousDirs || []) previousDirKeys.add(normalizePath(prev).toLowerCase());
338
+ }
339
+ // YottaCode 不接管:.yottacode 路径 + YOTTACODE_HOME 整棵子树(含任意自定义目录名)。
340
+ const yottacodeHome = envObject(options).YOTTACODE_HOME
341
+ ? path.resolve(envObject(options).YOTTACODE_HOME)
342
+ : null;
343
+ const isYottaCodePath = (dir) => {
344
+ if (!dir) return false;
345
+ if (agentDirs.isYottaCodeDir(dir)) return true;
346
+ if (!yottacodeHome) return false;
347
+ const resolved = path.resolve(dir);
348
+ const relative = path.relative(yottacodeHome, resolved);
349
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
350
+ };
351
+ const isExcluded = (meta) => Boolean(hostsRegistry.excludedEntryFor(registry, meta || {}));
352
+ const skipDir = (dir, agentId) => isYottaCodePath(dir) || isExcluded({ dir, agentId });
353
+
250
354
  for (const root of agentDirs.knownRoots(options)) {
355
+ const override = root.agentId ? overridesByAgent.get(String(root.agentId).toLowerCase()) : null;
356
+ if (override) {
357
+ if (skipDir(override.dir, root.agentId)) continue;
358
+ addRoot(roots, seen, override.dir, {
359
+ ...root,
360
+ label: override.label || root.label,
361
+ verified: true,
362
+ detection: 'override',
363
+ });
364
+ continue;
365
+ }
366
+ if (skipDir(root.dir, root.agentId)) continue;
251
367
  addRoot(roots, seen, root.dir, { ...root, detection: 'mapping' });
252
368
  }
253
369
  for (const root of agentDirs.envRoots(options)) {
370
+ if (skipDir(root.dir, root.agentId)) continue;
254
371
  addRoot(roots, seen, root.dir, { ...root, detection: root.env ? 'env' : 'workspace' });
255
372
  }
373
+ // 合并顺序:内置映射 → 环境变量 → 用户注册表(F2)→ 自动发现;
374
+ // 同 realpath 用户注册优先(分类 = 已核实,detection = user-registry)。
375
+ for (const item of registry.hosts || []) {
376
+ if (skipDir(item.dir, item.agentId)) continue;
377
+ addRoot(roots, seen, item.dir, {
378
+ detection: 'user-registry',
379
+ label: item.label || '自定义宿主',
380
+ agentId: item.agentId || null,
381
+ verified: true,
382
+ known: false,
383
+ registry: true,
384
+ manualState: item.manualState || null,
385
+ });
386
+ }
256
387
  for (const root of commonRoots(options)) {
257
388
  for (const found of scanForSkillDirs(root.dir, {
258
389
  maxDepth: root.maxDepth,
259
390
  baseDir: options.homeDir || os.homedir(),
260
391
  })) {
392
+ if (skipDir(found.dir, null)) continue;
393
+ // 被目录覆盖替换掉的旧映射目录不再进入默认发现 / 链接。
394
+ if (previousDirKeys.has(normalizePath(found.dir).toLowerCase())) continue;
261
395
  addRoot(roots, seen, found.dir, {
262
396
  detection: 'discovered',
263
397
  label: '自动发现:' + path.basename(found.parent),
@@ -265,6 +399,22 @@ function discoverHosts(options) {
265
399
  }
266
400
  }
267
401
 
402
+ // F3:目录证据与实体证据分开记录,判定生命周期状态。
403
+ const markers = installedMarkers(options);
404
+ for (const host of roots) {
405
+ const markerEvidence = markers.some((name) => markerMatchesHost(name, host));
406
+ host.markerEvidence = markerEvidence;
407
+ host.dirEvidence = host.exists;
408
+ host.evidence = {
409
+ dir: host.exists,
410
+ marker: markerEvidence,
411
+ registry: Boolean(host.registry),
412
+ manual: Boolean(host.manualState),
413
+ };
414
+ host.stateSource = host.manualState ? 'manual' : 'auto';
415
+ host.state = judgeHostState(host);
416
+ }
417
+
268
418
  const installed = [];
269
419
  const installedSeen = new Set();
270
420
  const addInstalled = (name, source) => {
@@ -275,20 +425,44 @@ function discoverHosts(options) {
275
425
  installedSeen.add(key);
276
426
  installed.push({ label, source, hasSkillsDir: false });
277
427
  };
278
- for (const name of installedMarkers(options)) {
428
+ // 0.29.1 U2:不接管(excluded)/ neverLink(YottaCode)宿主的应用标记同样跳过显示。
429
+ const isMarkerSuppressed = (name) => {
430
+ for (const [agentId, info] of Object.entries(agentDirs.AGENT_DIRS)) {
431
+ if (!info.neverLink) continue;
432
+ if (markerMatchesHost(name, { agentId, label: info.label })) return true;
433
+ }
434
+ return (registry.excluded || []).some((item) => {
435
+ if (item.kind !== 'agent') return false;
436
+ const info = agentDirs.AGENT_DIRS[String(item.value).toLowerCase()] || {};
437
+ return markerMatchesHost(name, { agentId: item.value, label: info.label || item.value });
438
+ });
439
+ };
440
+ for (const name of markers) {
441
+ if (isMarkerSuppressed(name)) continue;
279
442
  if (knownLabelMatches(name) || AGENT_HINT.test(name)) addInstalled(name, '应用标记');
280
443
  }
444
+ for (const host of roots) {
445
+ if (host.detection === 'user-registry') addInstalled(host.label, '用户注册');
446
+ }
447
+ // 0.29.1 U2:配对确定性优选(先收集全部候选,再按优先级选定),消除 last-write-wins。
448
+ const pairCandidates = new Map();
281
449
  for (const host of roots) {
282
450
  if (!host.exists) continue;
283
- addInstalled(host.label, host.known ? '已知宿主' : '自动发现');
284
- const item = installed.find((entry) => entry.label.toLowerCase() === String(host.label).toLowerCase());
285
- if (item) {
286
- item.hasSkillsDir = true;
287
- item.skillDir = host.dir;
288
- item.skillCount = host.skillCount;
289
- item.agentId = host.agentId;
451
+ if (!host.markerEvidence && host.detection !== 'user-registry') continue;
452
+ for (const item of installed) {
453
+ if (!markerMatchesHost(item.label, host)) continue;
454
+ if (!pairCandidates.has(item)) pairCandidates.set(item, []);
455
+ pairCandidates.get(item).push(host);
290
456
  }
291
457
  }
458
+ for (const [item, hosts] of pairCandidates) {
459
+ hosts.sort(compareHostCandidates);
460
+ const host = hosts[0];
461
+ item.hasSkillsDir = true;
462
+ item.skillDir = host.dir;
463
+ item.skillCount = host.skillCount;
464
+ item.agentId = host.agentId;
465
+ }
292
466
 
293
467
  return {
294
468
  generatedAt: new Date().toISOString(),
@@ -298,9 +472,25 @@ function discoverHosts(options) {
298
472
  };
299
473
  }
300
474
 
475
+ /**
476
+ * 0.29.1 U2:未配对且无技能目录的已安装标记(CLI / 面板共用单一真源)。
477
+ * 已配对(hasSkillsDir)或 label 与已显示宿主重复的标记不再出现在「无技能目录」区。
478
+ */
479
+ function unpairedMarkers(discovery) {
480
+ const shown = new Set();
481
+ for (const host of (discovery && discovery.hosts) || []) {
482
+ if (host.exists || host.state === 'marker-only') shown.add(String(host.label).toLowerCase());
483
+ }
484
+ return ((discovery && discovery.installed) || []).filter((item) =>
485
+ !item.hasSkillsDir && !shown.has(String(item.label).toLowerCase()));
486
+ }
487
+
301
488
  module.exports = {
302
489
  AGENT_HINT,
303
490
  discoverHosts,
304
491
  scanForSkillDirs,
305
492
  countSkills,
493
+ markerMatchesHost,
494
+ judgeHostState,
495
+ unpairedMarkers,
306
496
  };
@@ -44,6 +44,11 @@ const CLI_GLOBAL_OPTIONS = [
44
44
  { flag: '--port', arg: '<n>', what: 'view 面板端口(默认 8789)', when: '端口冲突或想固定端口时', caution: '仅 127.0.0.1 监听' },
45
45
  { flag: '--all', arg: '', what: 'hub link / unlink 作用于全部已核实宿主', when: '全量分发或解除时', caution: '' },
46
46
  { flag: '--include-discovered', arg: '', what: 'hub link / unlink / remove 时纳入自动发现目录', when: '确认要扩围到未核实目录时', caution: '默认只链已核实宿主;桥接目录永不链接' },
47
+ { flag: '--state', arg: '<state>', what: 'hub hosts 状态过滤(available / orphan / missing / marker-only / ignored);hub hosts mark 时为手动状态', when: '只看某类宿主、或手动标记宿主状态时', caution: '' },
48
+ { flag: '--label', arg: '<名称>', what: 'hub hosts add 的显示名称(默认取目录名)', when: '注册自定义宿主时', caution: '最长 48 字符,禁控制字符' },
49
+ { flag: '--id', arg: '<agentId>', what: 'hub hosts add / remove 的智能体标识', when: '按智能体注册或移除自定义宿主时', caution: '' },
50
+ { flag: '--purge', arg: '', what: 'hub hosts remove 清理残留目录(默认预览;--yes 执行)', when: '清理实体未确认的残留技能目录时', caution: '只删指向 Hub 的链接;目录移入回收站保留 7 天' },
51
+ { flag: '--clear', arg: '', what: 'hub hosts set 恢复内置映射(删除目录覆盖)', when: '想撤销目录覆盖时', caution: '' },
47
52
  { flag: '--scan', arg: '', what: 'hub adopt 只读预演(默认行为)', when: '先看收编候选与冲突时', caution: '' },
48
53
  { flag: '--apply', arg: '', what: 'hub adopt 执行收编', when: '确认候选后执行时', caution: '执行时逐项运行元信扫描;high / critical 默认阻断' },
49
54
  { flag: '--in-place', arg: '', what: 'hub adopt 原地登记,不复制', when: '想让 Hub 直接指向原目录时', caution: '原目录删除即断链;默认复制保真更安全' },
@@ -74,7 +79,7 @@ const CLI_HELP_MODEL = [
74
79
  {
75
80
  name: 'update',
76
81
  usage: 'update [--agent <name> | --dir <path>] [--installed-only] [--check [--scheduled] | --auto] [--only <a,b> | --domain <name>]',
77
- what: '增量更新已装技能(补齐缺失 / 升级版本不一致)',
82
+ what: '增量更新已装技能(补齐缺失 / 升级;只升不降:本地更高保留,--force 才显式降级)',
78
83
  when: '日常维护、只读检查更新、后台周检、自动跟进时',
79
84
  options: ['--agent', '--dir', '--installed-only', '--check', '--scheduled', '--auto', '--only', '--domain', '--dry-run', '--skip-scan'],
80
85
  },
@@ -85,6 +90,20 @@ const CLI_HELP_MODEL = [
85
90
  when: '执行前先确认影响面时',
86
91
  options: ['--dry-run'],
87
92
  },
93
+ {
94
+ name: 'install-self',
95
+ usage: 'install-self [--dir <path>] [--dry-run] [--force] [--json]',
96
+ what: '元阁独立安装:把管理引擎装到独立位置(默认 ~/.yottaskills/yotta-skills),与 Hub / 宿主分离',
97
+ when: '想把元阁装成独立 CLI(不依赖任何智能体)时',
98
+ options: ['--dir', '--dry-run', '--force', '--json'],
99
+ },
100
+ {
101
+ name: 'where',
102
+ usage: 'where [--json]',
103
+ what: '查看元阁位置:运行中 CLI / 独立安装目录(标记当前运行项)/ Hub 真源',
104
+ when: '确认元阁装在哪、是否独立安装时',
105
+ options: ['--json', '--hub'],
106
+ },
88
107
  ],
89
108
  },
90
109
  {
@@ -156,9 +175,16 @@ const CLI_HELP_MODEL = [
156
175
  when: '多智能体共享技能、统一管理技能目录时',
157
176
  options: ['--hub', '--all', '--include-discovered', '--dry-run', '--json'],
158
177
  subcommands: [
159
- { name: 'hosts', usage: 'hub hosts [--json]', what: '发现本机已装智能体与技能目录(已核实 / 自动发现 / 桥接)', when: '先看这台机器有哪些宿主目录时', options: ['--json'] },
178
+ { name: 'hosts', usage: 'hub hosts [--json] [--state <state>]', what: '发现宿主与技能目录(可用 / 残留 / 未创建 / 仅标记 / 桥接)', when: '先看这台机器有哪些宿主目录时', options: ['--json', '--state'] },
179
+ { name: 'hosts add', usage: 'hub hosts add <dir> [--label <名称>] [--id <agentId>] [--json]', what: '注册自定义宿主目录(纳入发现 / 分发 / 收编;不创建目录)', when: '宿主未被自动识别或识别错误时', options: ['--label', '--id', '--json'] },
180
+ { name: 'hosts remove', usage: 'hub hosts remove <dir | --id <agentId>> [--purge [--yes]] [--json]', what: '移除注册(绝不删目录);--purge = 清理残留目录(默认预览,--yes 执行)', when: '注销自定义宿主、清理残留目录时', options: ['--id', '--purge', '--yes', '--json'] },
181
+ { name: 'hosts list', usage: 'hub hosts list [--json]', what: '查看自定义宿主注册表(hosts.json)', when: '核对已注册的自定义目录时', options: ['--json'] },
182
+ { name: 'hosts mark', usage: 'hub hosts mark <dir> --state <available|orphan|ignored> [--json]', what: '手动标记宿主状态(可用 / 残留 / 忽略)', when: '实体证据不足需要人工确认时', options: ['--state', '--json'] },
183
+ { name: 'hosts exclude', usage: 'hub hosts exclude <agentId|dir> [--json]', what: '加入「不接管」名单(发现 / 显示 / 链接三层跳过)', when: '某宿主 / 目录不归元阁管理时', options: ['--json'] },
184
+ { name: 'hosts include', usage: 'hub hosts include <agentId|dir> [--json]', what: '恢复接管(移出「不接管」名单)', when: '重新纳入发现 / 链接时', options: ['--json'] },
185
+ { name: 'hosts set', usage: 'hub hosts set <agentId> --dir <dir> [--label <名称>] [--json] | hub hosts set <agentId> --clear', what: '目录覆盖:用户级覆盖内置映射;--clear 恢复默认', when: '宿主技能目录与内置映射不一致时', options: ['--dir', '--label', '--clear', '--json'] },
160
186
  { name: 'install', usage: 'hub install [skill...]', what: '把元技能装进 Hub 真源(清单 27 + 特殊家族 5)', when: '初始化 Hub 或补装技能时', options: ['--hub'] },
161
- { name: 'update', usage: 'hub update', what: '更新 Hub 内的元技能(非元技能不参与)', when: '日常维护 Hub 真源时', options: ['--hub'] },
187
+ { name: 'update', usage: 'hub update', what: '更新 Hub 内的元技能(非元技能不参与;只升不降)', when: '日常维护 Hub 真源时', options: ['--hub'] },
162
188
  { name: 'adopt', usage: 'hub adopt [--scan | --apply] [--include <a,b>] [--in-place]', what: '把各宿主已有技能收编进 Hub(默认复制保真)', when: '接管机器上已装的技能时', options: ['--scan', '--apply', '--include', '--in-place', '--force', '--skip-scan', '--allow-unverified'] },
163
189
  { name: 'refresh', usage: 'hub refresh <slug> --from <path>', what: '手动同步非元阁技能(无统一更新源)', when: '外部技能更新后刷新 Hub 副本时', options: ['--from'] },
164
190
  { name: 'link', usage: 'hub link [skill...] [--all | --agent <id> | --dir <path>] [--include-discovered] [--dry-run]', what: '把 Hub 技能链接到宿主目录(指定智能体 / 全量)', when: '分发技能到各智能体时', options: ['--all', '--agent', '--dir', '--include-discovered', '--force', '--dry-run'] },
@@ -240,6 +266,8 @@ const CLI_HELP_QUICK = [
240
266
  { cmd: 'update', example: 'yotta-skills update --check' },
241
267
  { cmd: 'update', example: 'yotta-skills update --auto' },
242
268
  { cmd: '--dry-run', example: 'yotta-skills install --dry-run --agent codex' },
269
+ { cmd: 'install-self', example: 'yotta-skills install-self' },
270
+ { cmd: 'where', example: 'yotta-skills where' },
243
271
  ],
244
272
  },
245
273
  {
@@ -265,6 +293,15 @@ const CLI_HELP_QUICK = [
265
293
  items: [
266
294
  { cmd: 'hub', example: 'yotta-skills hub' },
267
295
  { cmd: 'hub hosts', example: 'yotta-skills hub hosts' },
296
+ { cmd: 'hub hosts', example: 'yotta-skills hub hosts --state orphan' },
297
+ { cmd: 'hub hosts add', example: 'yotta-skills hub hosts add <技能目录>' },
298
+ { cmd: 'hub hosts list', example: 'yotta-skills hub hosts list' },
299
+ { cmd: 'hub hosts mark', example: 'yotta-skills hub hosts mark <技能目录> --state orphan' },
300
+ { cmd: 'hub hosts exclude', example: 'yotta-skills hub hosts exclude <agentId|目录>' },
301
+ { cmd: 'hub hosts include', example: 'yotta-skills hub hosts include <agentId|目录>' },
302
+ { cmd: 'hub hosts set', example: 'yotta-skills hub hosts set <agentId> --dir <目录>' },
303
+ { cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录>', note: '只移除注册,不删目录' },
304
+ { cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录> --purge', danger: true, note: '清理残留目录:先预览,--yes 才执行;目录入回收站保留 7 天' },
268
305
  { cmd: 'hub install', example: 'yotta-skills hub install' },
269
306
  { cmd: 'hub update', example: 'yotta-skills hub update' },
270
307
  { cmd: 'hub adopt', example: 'yotta-skills hub adopt --scan' },
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+ /**
3
+ * 残留目录清理核心(0.29.0 F3):CLI 与面板共用。
4
+ *
5
+ * 只处理 state=orphan 的宿主目录 —— 全量 unlink(只删指向 Hub 的链接,含死链)
6
+ * → 目录移入回收站(保留 7 天,跨卷走复制 + 校验 + 删源)→ 清注册 / 链接台账
7
+ * → 写审计。非 Hub 链接 / 非链接内容保留并报告。
8
+ *
9
+ * fail-closed:Hub 真源 / 子目录、锁与数据桥接目录、非残留状态(可用 / 仅标记 /
10
+ * 未创建 / 已忽略)一律拒绝;需要人工确认时先 hub hosts mark --state orphan。
11
+ */
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const hubLib = require('./hub');
16
+ const agentDirsLib = require('./agent-dirs');
17
+ const agentDiscoveryLib = require('./agent-discovery');
18
+ const hostsRegistryLib = require('./hosts-registry');
19
+
20
+ function purgeStamp(now) {
21
+ const d = now instanceof Date ? now : new Date();
22
+ const pad = (value) => String(value).padStart(2, '0');
23
+ return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' +
24
+ pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '-' + process.pid;
25
+ }
26
+
27
+ function safeOrphanName(value) {
28
+ const cleaned = String(value || '')
29
+ .replace(/[\\/:*?"<>|\s]+/g, '-')
30
+ .replace(/^-+|-+$/g, '');
31
+ return cleaned || 'host';
32
+ }
33
+
34
+ function pathKey(dir) {
35
+ let real = path.resolve(dir);
36
+ try {
37
+ real = fs.realpathSync.native(real);
38
+ } catch (_) {
39
+ try { real = fs.realpathSync(real); } catch (_) { /* keep resolved */ }
40
+ }
41
+ return process.platform === 'win32' ? real.toLowerCase() : real;
42
+ }
43
+
44
+ function isInside(root, target) {
45
+ const relative = path.relative(path.resolve(root), path.resolve(target));
46
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
47
+ }
48
+
49
+ /** 只读预检 + 清理计划(不写任何文件)。 */
50
+ function planHostPurge(options) {
51
+ const opts = options || {};
52
+ const hubDir = path.resolve(opts.hubDir);
53
+ const target = path.resolve(opts.dir);
54
+ const homeDir = opts.homeDir || os.homedir();
55
+ const env = opts.env || process.env;
56
+
57
+ if (!fs.existsSync(target)) {
58
+ return { ok: false, code: 400, error: '目录不存在: ' + target };
59
+ }
60
+ if (isInside(hubDir, target)) {
61
+ return { ok: false, code: 400, error: '该目录不允许残留清理(Hub 真源 / 子目录): ' + target };
62
+ }
63
+ if (agentDirsLib.isBridgeOnlyDir(target, { homeDir, env })) {
64
+ return { ok: false, code: 400, error: '锁 / 数据桥接目录不参与残留清理: ' + target };
65
+ }
66
+
67
+ const discovery = opts.discovery || agentDiscoveryLib.discoverHosts({ homeDir, env });
68
+ const host = discovery.hosts.find((item) => pathKey(item.dir) === pathKey(target));
69
+ const state = host ? host.state : null;
70
+ if (state !== 'orphan') {
71
+ return {
72
+ ok: false,
73
+ code: 400,
74
+ state,
75
+ error: '目标不是「残留(实体未确认)」状态' + (state ? '(当前: ' + state + ')' : '(不在发现列表)'),
76
+ };
77
+ }
78
+
79
+ const linkSlugs = [];
80
+ let keptLinks = 0;
81
+ let keptEntries = 0;
82
+ let entries = [];
83
+ try { entries = fs.readdirSync(target, { withFileTypes: true }); } catch (_) { entries = []; }
84
+ for (const entry of entries) {
85
+ const full = path.join(target, entry.name);
86
+ const cls = hubLib.classifyTarget(full, hubDir);
87
+ if (cls.kind === 'link') {
88
+ if (cls.inHub) linkSlugs.push(entry.name);
89
+ else keptLinks++;
90
+ } else if (cls.kind !== 'missing') {
91
+ keptEntries++;
92
+ }
93
+ }
94
+ const trashRoot = hubLib.resolveTrashRoot(hubDir, opts);
95
+ const label = host && host.label ? host.label : path.basename(target);
96
+ const trashPath = path.join(trashRoot, purgeStamp(opts.now), 'orphan-' + safeOrphanName(label));
97
+ return {
98
+ ok: true,
99
+ hubDir,
100
+ target,
101
+ state,
102
+ label,
103
+ linkSlugs,
104
+ keptLinks,
105
+ keptEntries,
106
+ trashRoot,
107
+ trashPath,
108
+ };
109
+ }
110
+
111
+ /** 执行清理:unlink → trash → 清注册 / 台账 → 审计。 */
112
+ function applyHostPurge(options) {
113
+ const opts = options || {};
114
+ const plan = planHostPurge(opts);
115
+ if (!plan.ok) return plan;
116
+
117
+ const unlinkResult = hubLib.unlinkSkills({
118
+ hubDir: plan.hubDir,
119
+ targetDir: plan.target,
120
+ slugs: plan.linkSlugs,
121
+ dryRun: false,
122
+ });
123
+ const unlinkedCount = (unlinkResult.results || [])
124
+ .filter((item) => item.status === 'unlinked').length;
125
+
126
+ fs.mkdirSync(path.dirname(plan.trashPath), { recursive: true });
127
+ hubLib.moveEntryAcrossDevices(plan.target, plan.trashPath);
128
+ try {
129
+ hubLib.pruneTrash(plan.trashRoot, { days: hubLib.TRASH_RETENTION_DAYS });
130
+ } catch (_) { /* best-effort */ }
131
+
132
+ const registryOpts = { homeDir: opts.homeDir, env: opts.env, hub: opts.hub };
133
+ const registry = hostsRegistryLib.readHostsRegistry(registryOpts);
134
+ if (registry.hosts.some((item) => pathKey(item.dir) === pathKey(plan.target))) {
135
+ hostsRegistryLib.removeHost(registryOpts, { dir: plan.target });
136
+ }
137
+
138
+ try {
139
+ hubLib.appendAudit(plan.hubDir, {
140
+ event: 'hosts.purge',
141
+ dir: plan.target,
142
+ label: plan.label,
143
+ state: plan.state,
144
+ trashedTo: plan.trashPath,
145
+ unlinked: unlinkedCount,
146
+ keptLinks: plan.keptLinks,
147
+ keptEntries: plan.keptEntries,
148
+ });
149
+ } catch (_) { /* 审计失败不阻断 */ }
150
+
151
+ return {
152
+ ok: true,
153
+ dir: plan.target,
154
+ state: plan.state,
155
+ unlinkCount: unlinkedCount,
156
+ keptLinks: plan.keptLinks,
157
+ keptEntries: plan.keptEntries,
158
+ trashedTo: plan.trashPath,
159
+ };
160
+ }
161
+
162
+ module.exports = {
163
+ planHostPurge,
164
+ applyHostPurge,
165
+ purgeStamp,
166
+ safeOrphanName,
167
+ };