@yottameta/yotta-skills-plugin 0.27.0 → 0.28.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.
@@ -40,7 +40,7 @@ const AGENT_DIRS = {
40
40
  fx: { label: 'fx', dirs: ['.fx/skills'] },
41
41
  gemini: { label: 'Gemini CLI', dirs: ['.gemini/skills', '.agents/skills'] },
42
42
  copilot: { label: 'GitHub Copilot', dirs: ['.copilot/skills', '.agents/skills'] },
43
- goose: { label: 'Goose', dirs: ['.config/goose/skills', '.goose/skills'] },
43
+ goose: { label: 'Goose', dirs: ['.config/goose/skills', '.agents/skills'] },
44
44
  grok: { label: 'Grok Build', dirs: ['.grok/skills'] },
45
45
  hermes: { label: 'Hermes Agent', dirs: ['.hermes/skills'] },
46
46
  'inference-sh': { label: 'inference.sh', dirs: ['.inferencesh/skills'] },
@@ -54,6 +54,7 @@ const AGENT_DIRS = {
54
54
  kode: { label: 'Kode', dirs: ['.kode/skills'] },
55
55
  lingma: { label: 'Lingma', dirs: ['.lingma/skills'] },
56
56
  mcpjam: { label: 'MCPJam', dirs: ['.mcpjam/skills'] },
57
+ mimocode: { label: 'MiMo Code', dirs: ['.config/mimocode/skills'] },
57
58
  minimax: { label: 'MiniMax Code', dirs: ['.minimax/skills'] },
58
59
  'mistral-vibe': { label: 'Mistral Vibe', dirs: ['.vibe/skills'] },
59
60
  moxby: { label: 'Moxby', dirs: ['.moxby/skills'] },
@@ -68,6 +69,7 @@ const AGENT_DIRS = {
68
69
  'qoder-cn': { label: 'Qoder CN', dirs: ['.qoder-cn/skills'] },
69
70
  qwen: { label: 'Qwen Code', dirs: ['.qwen/skills'] },
70
71
  reasonix: { label: 'Reasonix', dirs: ['.reasonix/skills'] },
72
+ replit: { label: 'Replit', dirs: ['.config/agents/skills'] },
71
73
  rovodev: { label: 'Rovo Dev', dirs: ['.rovodev/skills'] },
72
74
  roo: { label: 'Roo Code', dirs: ['.roo/skills'] },
73
75
  'sarvam-code': { label: 'Sarvam Code', dirs: ['.sarvam/skills', '.agents/skills'] },
@@ -85,14 +87,30 @@ const AGENT_DIRS = {
85
87
  pochi: { label: 'Pochi', dirs: ['.pochi/skills'] },
86
88
  adal: { label: 'AdaL', dirs: ['.adal/skills'] },
87
89
  dsh: { label: 'DSH / DeepSeek Harness', dirs: ['.dsh/skills'] },
88
- yottacode: { label: 'YottaCode', dirs: ['.yottacode/skills'] },
89
- box: { label: 'Box Agent', dirs: ['.box-agent/skills'] },
90
+ yottacode: { label: 'YottaCode', dirs: ['.yottacode/skills'], verified: false },
91
+ box: { label: 'Box Agent', dirs: ['.box-agent/skills'], verified: false },
90
92
  lmstudio: { label: 'LM Studio', dirs: ['.lmstudio/skills'] },
91
93
  ccswitch: { label: 'CC Switch', dirs: ['.cc-switch/skills'] },
92
94
  agents: { label: '通用 AGENTS.md', dirs: ['.agents/skills'] },
93
- universal: { label: 'Universal .agents', dirs: ['.agents/skills'] },
95
+ universal: { label: 'Universal .agents', dirs: ['.config/agents/skills', '.agents/skills'] },
94
96
  };
95
97
 
98
+ /**
99
+ * Rel paths that follow XDG_CONFIG_HOME when it is set.
100
+ *
101
+ * Verified against the upstream Vercel Labs `skills` CLI v1.7.0 table
102
+ * (2026-10-03): only amp / universal / replit (`configHome/agents/skills`),
103
+ * devin (`configHome/devin/skills`) and opencode (`configHome/opencode/skills`)
104
+ * resolve through the config home. Everything else uses the literal
105
+ * `~/.config/...` path even when XDG_CONFIG_HOME is set (crush / kimchi per the
106
+ * upstream table; goose follows the machine-verified `~/.config/goose`).
107
+ */
108
+ const XDG_CONFIG_RELS = new Set([
109
+ '.config/agents/skills',
110
+ '.config/devin/skills',
111
+ '.config/opencode/skills',
112
+ ]);
113
+
96
114
  function envPath(env, key) {
97
115
  const value = env && env[key];
98
116
  return value ? String(value) : null;
@@ -119,8 +137,11 @@ function resolveUserDir(rel, options) {
119
137
  return path.join(envPath(env, 'CODEX_HOME') || path.join(home, '.codex'), 'skills');
120
138
  }
121
139
  if (rel.startsWith('.config/')) {
122
- const base = envPath(env, 'XDG_CONFIG_HOME') || path.join(home, '.config');
123
- return path.join(base, ...rel.slice('.config/'.length).split('/'));
140
+ const xdg = envPath(env, 'XDG_CONFIG_HOME');
141
+ if (xdg && XDG_CONFIG_RELS.has(rel)) {
142
+ return path.join(xdg, ...rel.slice('.config/'.length).split('/'));
143
+ }
144
+ return path.join(home, rel);
124
145
  }
125
146
  if (rel === '.openclaw/skills') {
126
147
  return path.join(envPath(env, 'OPENCLAW_STATE_DIR') || path.join(home, '.openclaw'), 'skills');
@@ -150,16 +171,16 @@ function resolveUserDir(rel, options) {
150
171
  function knownRoots(options) {
151
172
  const roots = [];
152
173
  const seen = new Set();
153
- const add = (dir, agentId, label) => {
174
+ const add = (dir, agentId, label, verified) => {
154
175
  if (!dir) return;
155
176
  const resolved = path.resolve(dir);
156
177
  if (seen.has(resolved)) return;
157
178
  seen.add(resolved);
158
- roots.push({ dir: resolved, agentId, label, known: true });
179
+ roots.push({ dir: resolved, agentId, label, known: true, verified: verified !== false });
159
180
  };
160
181
  for (const [agentId, info] of Object.entries(AGENT_DIRS)) {
161
182
  for (const rel of info.dirs) {
162
- add(resolveUserDir(rel, options), agentId, info.label);
183
+ add(resolveUserDir(rel, options), agentId, info.label, info.verified !== false);
163
184
  }
164
185
  }
165
186
  return roots;
@@ -169,15 +190,25 @@ function knownRoots(options) {
169
190
  function envRoots(options) {
170
191
  const env = envObject(options);
171
192
  const roots = [];
172
- const add = (dir, agentId, label) => {
193
+ const add = (dir, agentId, label, extra) => {
173
194
  if (!dir) return;
174
- roots.push({ dir: path.resolve(dir), agentId: agentId || null, label: label || '环境变量指定目录', known: false, env: true });
195
+ roots.push({
196
+ dir: path.resolve(dir),
197
+ agentId: agentId || null,
198
+ label: label || '环境变量指定目录',
199
+ known: false,
200
+ env: true,
201
+ verified: true,
202
+ ...(extra || {}),
203
+ });
175
204
  };
176
205
 
177
206
  add(envPath(env, 'CODEX_HOME') && path.join(env.CODEX_HOME, 'skills'), 'codex', 'Codex(CODEX_HOME)');
178
207
  add(envPath(env, 'XDG_CONFIG_HOME') && path.join(env.XDG_CONFIG_HOME, 'opencode', 'skills'), 'opencode', 'OpenCode(XDG_CONFIG_HOME)');
179
- add(envPath(env, 'XDG_STATE_HOME') && path.join(env.XDG_STATE_HOME, 'skills'), null, 'XDG_STATE_HOME/skills');
180
- add(envPath(env, 'XDG_DATA_HOME') && path.join(env.XDG_DATA_HOME, 'skills'), null, 'XDG_DATA_HOME/skills');
208
+ add(envPath(env, 'XDG_STATE_HOME') && path.join(env.XDG_STATE_HOME, 'skills'), null,
209
+ 'XDG_STATE_HOME/skills(锁桥接,不链接)', { bridgeOnly: true });
210
+ add(envPath(env, 'XDG_DATA_HOME') && path.join(env.XDG_DATA_HOME, 'skills'), null,
211
+ 'XDG_DATA_HOME/skills(数据桥接,不链接)', { bridgeOnly: true });
181
212
  add(envPath(env, 'DSH_HOME') && path.join(env.DSH_HOME, 'skills'), 'dsh', 'DSH(DSH_HOME)');
182
213
  add(envPath(env, 'DSH_AGENTS_HOME') && path.join(env.DSH_AGENTS_HOME, 'skills'), 'dsh', 'DSH Agents(DSH_AGENTS_HOME)');
183
214
  add(envPath(env, 'OPENCLAW_STATE_DIR') && path.join(env.OPENCLAW_STATE_DIR, 'skills'), 'openclaw', 'OpenClaw(OPENCLAW_STATE_DIR)');
@@ -188,14 +219,40 @@ function envRoots(options) {
188
219
  .split(path.delimiter)
189
220
  .map((item) => item.trim())
190
221
  .filter(Boolean);
191
- for (const dir of extra) add(dir, null, 'YOTTA_SKILLS_DISCOVERY_ROOTS');
222
+ for (const dir of extra) add(dir, null, 'YOTTA_SKILLS_DISCOVERY_ROOTS', { verified: false });
192
223
 
193
224
  return roots;
194
225
  }
195
226
 
227
+ /**
228
+ * Lock / data bridge directories: the official `skills` CLI keeps its lock in
229
+ * `$XDG_STATE_HOME/skills`, and `$XDG_DATA_HOME/skills` is a data bridge.
230
+ * Neither is a host skill-loading directory, so both are never link targets.
231
+ */
232
+ function bridgeOnlyDirs(options) {
233
+ const env = envObject(options);
234
+ const dirs = [];
235
+ if (envPath(env, 'XDG_STATE_HOME')) dirs.push(path.join(env.XDG_STATE_HOME, 'skills'));
236
+ if (envPath(env, 'XDG_DATA_HOME')) dirs.push(path.join(env.XDG_DATA_HOME, 'skills'));
237
+ return dirs.map((dir) => path.resolve(dir));
238
+ }
239
+
240
+ function isBridgeOnlyDir(dir, options) {
241
+ if (!dir) return false;
242
+ const target = path.resolve(dir);
243
+ for (const bridge of bridgeOnlyDirs(options)) {
244
+ const relative = path.relative(bridge, target);
245
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) return true;
246
+ }
247
+ return false;
248
+ }
249
+
196
250
  module.exports = {
197
251
  AGENT_DIRS,
252
+ XDG_CONFIG_RELS,
198
253
  resolveUserDir,
199
254
  knownRoots,
200
255
  envRoots,
256
+ bridgeOnlyDirs,
257
+ isBridgeOnlyDir,
201
258
  };
@@ -101,6 +101,8 @@ function addRoot(list, seen, dir, meta) {
101
101
  if (meta && meta.known && existing) existing.known = true;
102
102
  if (meta && meta.agentId && !existing.agentId) existing.agentId = meta.agentId;
103
103
  if (meta && meta.label && existing.label === '自动发现目录') existing.label = meta.label;
104
+ if (meta && meta.verified) existing.verified = true;
105
+ if (meta && meta.bridgeOnly) existing.bridgeOnly = true;
104
106
  return;
105
107
  }
106
108
  const record = {
@@ -109,6 +111,8 @@ function addRoot(list, seen, dir, meta) {
109
111
  label: (meta && meta.label) || '自动发现目录',
110
112
  known: Boolean(meta && meta.known),
111
113
  env: Boolean(meta && meta.env),
114
+ verified: Boolean(meta && meta.verified),
115
+ bridgeOnly: Boolean(meta && meta.bridgeOnly),
112
116
  detection: (meta && meta.detection) || 'mapping',
113
117
  exists: isDirectory(resolved),
114
118
  };
@@ -169,8 +173,8 @@ function commonRoots(options) {
169
173
  add(env.ProgramFiles, 2);
170
174
  add(env['ProgramFiles(x86)'], 2);
171
175
  add(env.XDG_CONFIG_HOME, 3);
172
- add(env.XDG_STATE_HOME, 3);
173
- add(env.XDG_DATA_HOME, 3);
176
+ // XDG_STATE_HOME / XDG_DATA_HOME are lock / data bridges, not skill roots:
177
+ // never scan them for link targets (see agent-dirs envRoots bridgeOnly).
174
178
  add(env.DSH_HOME, 3);
175
179
  add(env.DSH_AGENTS_HOME, 3);
176
180
  add(env.OPENCLAW_STATE_DIR, 2);
@@ -212,6 +212,9 @@ function hostsPayload(ctx) {
212
212
  summary: {
213
213
  hosts: hosts.length,
214
214
  existing: hosts.filter((host) => host.exists).length,
215
+ verified: hosts.filter((host) => host.exists && host.verified && !host.bridgeOnly).length,
216
+ discovered: hosts.filter((host) => host.exists && !host.verified && !host.bridgeOnly).length,
217
+ bridge: hosts.filter((host) => host.exists && host.bridgeOnly).length,
215
218
  installedMarks: found.installed.length,
216
219
  links: links.length,
217
220
  brokenLinks: links.filter((link) => link.status !== 'ok').length,
@@ -245,6 +248,9 @@ function linkPlanPayload(ctx, targetDir) {
245
248
  if (!host) {
246
249
  return { error: '目标目录不在已发现的宿主目录中;请先在宿主矩阵中确认该目录。', code: 400 };
247
250
  }
251
+ if (host.bridgeOnly) {
252
+ return { error: '该目录是锁 / 数据桥接目录(XDG_STATE_HOME/skills 或 XDG_DATA_HOME/skills),永不作为链接目标。', code: 400 };
253
+ }
248
254
  const result = hubLib.linkSkills({
249
255
  hubDir: ctx.hubDir,
250
256
  targetDir: host.dir,
@@ -407,6 +413,9 @@ function writeLink(ctx, body) {
407
413
  const targetDir = String(body.targetDir || '');
408
414
  const host = found.hosts.find((item) => sameDir(item.dir, targetDir));
409
415
  if (!host) return { code: 400, payload: { error: '目标目录不在已发现的宿主目录中。' } };
416
+ if (host.bridgeOnly) {
417
+ return { code: 400, payload: { error: '该目录是锁 / 数据桥接目录(XDG_STATE_HOME/skills 或 XDG_DATA_HOME/skills),永不作为链接目标。' } };
418
+ }
410
419
  if (body.force) {
411
420
  return {
412
421
  code: 400,
@@ -154,6 +154,11 @@ function samePath(left, right) {
154
154
  return a === b;
155
155
  }
156
156
 
157
+ function pathKey(dir) {
158
+ const real = safeRealpath(dir);
159
+ return process.platform === 'win32' ? real.toLowerCase() : real;
160
+ }
161
+
157
162
  function isInside(root, target) {
158
163
  const base = safeRealpath(root);
159
164
  const candidate = safeRealpath(target);
@@ -189,6 +194,161 @@ function hashTree(dir) {
189
194
  return hash.digest('hex');
190
195
  }
191
196
 
197
+ /** 统计目录树中的普通文件数与总字节数(不跟随链接;跨卷复制校验用)。 */
198
+ function treeStats(dir) {
199
+ let files = 0;
200
+ let bytes = 0;
201
+ const walk = (current) => {
202
+ for (const name of fs.readdirSync(current)) {
203
+ const full = path.join(current, name);
204
+ const stat = fs.lstatSync(full);
205
+ if (stat.isSymbolicLink()) continue;
206
+ if (stat.isDirectory()) walk(full);
207
+ else if (stat.isFile()) {
208
+ files += 1;
209
+ bytes += stat.size;
210
+ }
211
+ }
212
+ };
213
+ walk(path.resolve(dir));
214
+ return { files, bytes };
215
+ }
216
+
217
+ /** 重建链接条目:目录优先用 junction(Windows)/ dir(POSIX),文件用 file。 */
218
+ function copyLinkEntry(src, dest) {
219
+ const target = fs.readlinkSync(src);
220
+ let kinds;
221
+ if (process.platform === 'win32') {
222
+ let isDir = null;
223
+ try {
224
+ isDir = fs.statSync(src).isDirectory();
225
+ } catch (_) {
226
+ isDir = null;
227
+ }
228
+ kinds = isDir === false ? ['file', 'junction'] : ['junction', 'file'];
229
+ } else {
230
+ let isDir = false;
231
+ try {
232
+ isDir = fs.statSync(src).isDirectory();
233
+ } catch (_) {
234
+ isDir = false;
235
+ }
236
+ kinds = [isDir ? 'dir' : 'file'];
237
+ }
238
+ let lastError = null;
239
+ for (const kind of kinds) {
240
+ try {
241
+ fs.symlinkSync(target, dest, kind);
242
+ return;
243
+ } catch (error) {
244
+ lastError = error;
245
+ }
246
+ }
247
+ throw lastError || new Error('无法重建链接:' + src);
248
+ }
249
+
250
+ /** 逐条目复制目录树(保真:含链接、空目录与全部普通文件)。 */
251
+ function copyEntryFaithful(src, dest) {
252
+ const stat = fs.lstatSync(src);
253
+ if (stat.isSymbolicLink()) {
254
+ copyLinkEntry(src, dest);
255
+ return;
256
+ }
257
+ if (stat.isDirectory()) {
258
+ fs.mkdirSync(dest, { recursive: true });
259
+ for (const name of fs.readdirSync(src)) {
260
+ const child = path.join(src, name);
261
+ const childStat = fs.lstatSync(child);
262
+ if (childStat.isSymbolicLink()) copyLinkEntry(child, path.join(dest, name));
263
+ else if (childStat.isDirectory()) copyEntryFaithful(child, path.join(dest, name));
264
+ else if (childStat.isFile()) fs.copyFileSync(child, path.join(dest, name));
265
+ else throw new Error('不支持的条目类型:' + child);
266
+ }
267
+ return;
268
+ }
269
+ if (stat.isFile()) {
270
+ fs.copyFileSync(src, dest);
271
+ return;
272
+ }
273
+ throw new Error('不支持的条目类型:' + src);
274
+ }
275
+
276
+ /** 删除条目:链接只删链接本身,不触碰目标。 */
277
+ function removeEntryFaithful(entryPath) {
278
+ const stat = fs.lstatSync(entryPath);
279
+ if (stat.isSymbolicLink()) {
280
+ removeDirLink(entryPath);
281
+ return;
282
+ }
283
+ fs.rmSync(entryPath, { recursive: true, force: true });
284
+ }
285
+
286
+ /** 校验复制结果:链接比对目标,目录比对 treeHash + 文件数 + 字节数。 */
287
+ function verifyEntryCopy(src, dest) {
288
+ const srcStat = fs.lstatSync(src);
289
+ const destStat = fs.lstatSync(dest);
290
+ if (srcStat.isSymbolicLink() !== destStat.isSymbolicLink()) {
291
+ return { ok: false, reason: '条目类型不一致' };
292
+ }
293
+ if (srcStat.isSymbolicLink()) {
294
+ if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) {
295
+ return { ok: false, reason: '链接目标不一致' };
296
+ }
297
+ return { ok: true };
298
+ }
299
+ if (hashTree(src) !== hashTree(dest)) {
300
+ return { ok: false, reason: 'treeHash 不一致' };
301
+ }
302
+ const from = treeStats(src);
303
+ const to = treeStats(dest);
304
+ if (from.files !== to.files || from.bytes !== to.bytes) {
305
+ return {
306
+ ok: false,
307
+ reason: '文件数 / 字节数不一致(源 ' + from.files + ' 个 / ' + from.bytes + ' 字节;副本 ' +
308
+ to.files + ' 个 / ' + to.bytes + ' 字节)',
309
+ };
310
+ }
311
+ return { ok: true };
312
+ }
313
+
314
+ /**
315
+ * 跨卷安全移动:优先 rename;EXDEV(跨盘)时回退「复制 → 校验 → 删除源」。
316
+ * 任一步失败都不静默丢数据:源完整则回滚副本;源已不完整则保留完整副本并报出路径。
317
+ */
318
+ function moveEntryAcrossDevices(src, dest, options) {
319
+ const opts = options || {};
320
+ const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
321
+ try {
322
+ renameEntry(src, dest);
323
+ return { method: 'rename' };
324
+ } catch (error) {
325
+ if (!error || error.code !== 'EXDEV') throw error;
326
+ }
327
+ copyEntryFaithful(src, dest);
328
+ let check;
329
+ try {
330
+ check = verifyEntryCopy(src, dest);
331
+ } catch (error) {
332
+ check = { ok: false, reason: error.message };
333
+ }
334
+ if (!check.ok) {
335
+ try { removeEntryFaithful(dest); } catch (_) { /* 保留现场供排查 */ }
336
+ throw new Error('跨卷复制校验失败(' + check.reason + ');源目录未动,副本已清理');
337
+ }
338
+ try {
339
+ removeEntryFaithful(src);
340
+ } catch (error) {
341
+ let intact = false;
342
+ try { intact = verifyEntryCopy(dest, src).ok; } catch (_) { intact = false; }
343
+ if (intact) {
344
+ try { removeEntryFaithful(dest); } catch (_) { /* 保留现场供排查 */ }
345
+ throw new Error('跨卷复制完成但源删除失败:' + error.message + ';源目录完整,副本已回滚');
346
+ }
347
+ throw new Error('跨卷复制完成但源删除失败:' + error.message + ';完整副本保留在 ' + dest);
348
+ }
349
+ return { method: 'copy' };
350
+ }
351
+
192
352
  function scanHubSkills(hubDir) {
193
353
  const root = path.resolve(hubDir);
194
354
  const result = [];
@@ -491,6 +651,7 @@ function linkSkills(options) {
491
651
  const results = [];
492
652
  const trashRoot = resolveTrashRoot(hubDir, opts);
493
653
  const makeLink = typeof opts.createLink === 'function' ? opts.createLink : createDirLink;
654
+ const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
494
655
  const stamp = trashRunStamp(opts.now);
495
656
  const hostName = safeHostName(opts.label || path.basename(targetDir));
496
657
  let prunedTrash = 0;
@@ -520,6 +681,7 @@ function linkSkills(options) {
520
681
  const moves = [];
521
682
  const skipped = [];
522
683
  let block = null;
684
+ let replaceExactLink = false;
523
685
 
524
686
  const evaluate = (copy, isExact) => {
525
687
  if (!copy) return;
@@ -532,7 +694,11 @@ function linkSkills(options) {
532
694
  return;
533
695
  }
534
696
  if (isExact) {
535
- block = { status: 'conflict', note: '目标已存在指向 Hub 之外的链接(默认跳过;确认后可加 --force 替换)' };
697
+ if (force) {
698
+ replaceExactLink = true;
699
+ } else {
700
+ block = { status: 'conflict', note: '目标已存在指向 Hub 之外的链接(默认跳过;确认后可加 --force 替换)' };
701
+ }
536
702
  } else {
537
703
  skipped.push(copy.name + '(外部链接,保留)');
538
704
  }
@@ -593,7 +759,8 @@ function linkSkills(options) {
593
759
  slug,
594
760
  status: 'would-converge',
595
761
  target,
596
- note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)后建立链接:' +
762
+ note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)' +
763
+ (replaceExactLink ? '并替换指向 Hub 之外的链接' : '') + '后建立链接:' +
597
764
  moves.map((item) => item.name).join('、'),
598
765
  moves: moves.map((item) => ({ from: item.dir, name: item.name, version: item.version || null })),
599
766
  trashDir: trashRoot,
@@ -601,11 +768,14 @@ function linkSkills(options) {
601
768
  continue;
602
769
  }
603
770
  if (moves.length === 0 && dryRun) {
604
- results.push({ slug, status: 'would-link', target, note: '将建立链接' });
771
+ results.push(replaceExactLink
772
+ ? { slug, status: 'would-replace', target, note: '将替换指向 Hub 之外的链接并建立链接' }
773
+ : { slug, status: 'would-link', target, note: '将建立链接' });
605
774
  continue;
606
775
  }
607
776
  if (moves.length === 0) {
608
777
  try {
778
+ if (replaceExactLink) removeDirLink(target);
609
779
  makeLink(skill.dir, target);
610
780
  const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
611
781
  const record = {
@@ -624,15 +794,25 @@ function linkSkills(options) {
624
794
  slug,
625
795
  status: 'linked',
626
796
  target,
627
- note: '已建立链接' + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
797
+ note: (replaceExactLink
798
+ ? '已替换指向 Hub 之外的链接并建立链接(仅移除链接本身,目标目录未动)'
799
+ : '已建立链接') + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
800
+ });
801
+ appendAudit(hubDir, {
802
+ event: 'link',
803
+ slug,
804
+ target,
805
+ hubDir: skill.dir,
806
+ backup: null,
807
+ ...(replaceExactLink ? { replacedExternalLink: true } : {}),
628
808
  });
629
- appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup: null });
630
809
  } catch (error) {
631
810
  results.push({
632
811
  slug,
633
812
  status: 'error',
634
813
  target,
635
- note: error.message + '(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
814
+ note: (replaceExactLink ? '替换失败:' : '') + error.message +
815
+ '(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
636
816
  });
637
817
  }
638
818
  continue;
@@ -642,10 +822,19 @@ function linkSkills(options) {
642
822
  for (const item of moves) {
643
823
  const dest = path.join(trashRoot, stamp, hostName, item.name);
644
824
  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 });
825
+ const transfer = moveEntryAcrossDevices(item.dir, dest, { renameEntry });
826
+ moved.push({
827
+ from: item.dir,
828
+ to: dest,
829
+ name: item.name,
830
+ version: item.version || null,
831
+ method: transfer.method,
832
+ });
833
+ }
834
+ if (!exactHealthy) {
835
+ if (replaceExactLink) removeDirLink(target);
836
+ makeLink(skill.dir, target);
647
837
  }
648
- if (!exactHealthy) makeLink(skill.dir, target);
649
838
  const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
650
839
  const record = {
651
840
  slug,
@@ -660,11 +849,14 @@ function linkSkills(options) {
660
849
  };
661
850
  if (index >= 0) links[index] = record;
662
851
  else links.push(record);
852
+ const copiedCount = moved.filter((item) => item.method === 'copy').length;
663
853
  results.push({
664
854
  slug,
665
855
  status: 'linked',
666
856
  target,
667
857
  note: '已收敛 ' + moved.length + ' 份旧副本(回收站保留 ' + TRASH_RETENTION_DAYS + ' 天)并建立链接' +
858
+ (replaceExactLink ? '(已替换指向 Hub 之外的链接;仅移除链接本身)' : '') +
859
+ (copiedCount ? '(其中 ' + copiedCount + ' 份跨卷复制,校验通过)' : '') +
668
860
  (skipped.length ? ';保留:' + skipped.join('、') : ''),
669
861
  moved,
670
862
  trashDir: trashRoot,
@@ -677,13 +869,14 @@ function linkSkills(options) {
677
869
  hubVersion: skill.version,
678
870
  moved,
679
871
  trashDir: trashRoot,
872
+ ...(replaceExactLink ? { replacedExternalLink: true } : {}),
680
873
  });
681
874
  } catch (error) {
682
875
  let restored = true;
683
876
  for (const item of moved.slice().reverse()) {
684
877
  try {
685
878
  fs.mkdirSync(path.dirname(item.from), { recursive: true });
686
- fs.renameSync(item.to, item.from);
879
+ moveEntryAcrossDevices(item.to, item.from, { renameEntry });
687
880
  } catch (_) { restored = false; }
688
881
  }
689
882
  results.push({
@@ -976,6 +1169,23 @@ function doctor(options) {
976
1169
  link.status === 'ok' ? null : '运行 hub link --all 重建,或 hub unlink 清理断链');
977
1170
  }
978
1171
 
1172
+ const scopeDirs = Array.isArray(opts.linkScopeDirs) ? opts.linkScopeDirs : null;
1173
+ if (scopeDirs && scopeDirs.length > 0) {
1174
+ const scope = new Set(scopeDirs.map((dir) => pathKey(dir)));
1175
+ const outside = new Map();
1176
+ for (const link of links) {
1177
+ if (!link.dir) continue;
1178
+ if (scope.has(pathKey(link.dir))) continue;
1179
+ if (!outside.has(link.dir)) outside.set(link.dir, []);
1180
+ outside.get(link.dir).push(link.slug);
1181
+ }
1182
+ for (const [dir, slugs] of outside) {
1183
+ add('link_scope:' + dir, false, 'warning',
1184
+ '链接位于默认范围之外: ' + dir + '(' + slugs.length + ' 条)',
1185
+ '默认范围 = 已核实宿主;如需保留请用 hub link --dir 或 --include-discovered 显式重建,否则用 hub unlink --dir "' + dir + '" 清理');
1186
+ }
1187
+ }
1188
+
979
1189
  try {
980
1190
  singleSourceChecks(opts, add);
981
1191
  } catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
@@ -1009,6 +1219,7 @@ module.exports = {
1009
1219
  writeLinkState,
1010
1220
  appendAudit,
1011
1221
  hashTree,
1222
+ moveEntryAcrossDevices,
1012
1223
  scanHubSkills,
1013
1224
  syncHubState,
1014
1225
  familySlugSet,
@@ -23,12 +23,34 @@ symlink),升级只改真源,已链接宿主即时生效。
23
23
  2. 版本闸门:Hub 版本 ≥ 宿主副本版本才收敛;宿主更高 → 跳过并提示先 `hub update`;
24
24
  版本无法解析 → 跳过提示(fail-safe);
25
25
  3. 旧副本移入回收站(保留 7 天、输出恢复路径)→ 建立指向 Hub 的链接;
26
+ 跨盘(源目录在其它卷,或经 junction / symlink 落在其它卷)时 `rename`
27
+ 会报 `EXDEV`,自动回退「复制 → 校验(treeHash + 文件数 + 字节数)→ 删除源」;
28
+ 建链失败后的恢复同样支持跨盘回退,校验不通过不会删除源;
26
29
  4. 先移后链;建链失败自动尝试恢复已移动副本,恢复失败时报出回收站路径;
30
+ 目标条目为指向 Hub 之外的链接时默认跳过;显式 `--force` 可替换为 Hub 链接
31
+ (仅移除链接本身,目标目录不动;dry-run 报 `would-replace`);
27
32
  5. 外部技能保持原行为:同名冲突默认跳过,不自动删、不自动更新。
28
33
 
29
34
  落地后跑一次 `hub link --all` 即可完成现存收敛。`hub doctor` 的
30
35
  `single_source:*` 检查只读报告宿主中的多份副本与版本参差,不自动修改。
31
36
 
37
+ ## 链接范围(0.28.0 起)
38
+
39
+ `hub hosts` 把发现的目录分为三类,`hub link --all` / `hub unlink --all` 的
40
+ **默认范围只含已核实宿主**:
41
+
42
+ - **已核实(verified)**:映射经公开宿主表(Vercel Labs `skills@1.7.0`)、官方
43
+ 文档或本机自证(应用数据 / 配置 / 管理痕迹)核实 —— 默认链接;
44
+ - **自动发现(discovered)**:文件系统启发式扫到的目录 —— 默认不链,
45
+ `--include-discovered` 显式纳入;
46
+ - **桥接(bridge)**:`XDG_STATE_HOME/skills`(官方 skills CLI 锁目录)与
47
+ `XDG_DATA_HOME/skills`(数据桥接)—— 永不作为链接目标,`--dir` 指向时
48
+ 也会被拒绝(fail-closed)。
49
+
50
+ 未核实映射(如无文档 / 无自证的宿主)默认不链,可用 `--dir <目录>` 显式指定。
51
+ `hub doctor` 的 `link_scope:*` 检查只读报告「位于默认范围之外」的已建链接,
52
+ 并给出 `hub unlink --dir <目录>` 的清理提示;`hub hosts` 输出三类的本机计数。
53
+
32
54
  ## 命令
33
55
 
34
56
  ```bash
@@ -50,11 +72,14 @@ npx -y @yottameta/yotta-skills hub adopt --apply
50
72
  # 非元阁来源技能没有统一更新源:手动从指定目录刷新
51
73
  npx -y @yottameta/yotta-skills hub refresh my-skill --from <技能目录>
52
74
 
53
- # 分发到全部已发现宿主:元技能链接时收敛旧副本(回收站 7 天);
75
+ # 分发到全部已核实宿主(默认范围):元技能链接时收敛旧副本(回收站 7 天);
54
76
  # 外部技能同名真目录默认跳过(--force 才备份并替换)
55
77
  npx -y @yottameta/yotta-skills hub link --all
56
78
 
57
- # 只删除链接,不动 Hub 真源
79
+ # 显式把自动发现目录一并纳入(桥接目录永不链接)
80
+ npx -y @yottameta/yotta-skills hub link --all --include-discovered
81
+
82
+ # 只删除链接,不动 Hub 真源;清理范围外链接用 --dir <目录> 精确指定
58
83
  npx -y @yottameta/yotta-skills hub unlink --all
59
84
 
60
85
  # 查看来源、版本、链接宿主与异常
@@ -64,11 +89,14 @@ npx -y @yottameta/yotta-skills hub doctor
64
89
 
65
90
  ## 宿主发现口径
66
91
 
67
- 1. 已收录宿主映射(兼容 Vercel Labs `skills` CLI / SkillCat 的公开宿主表);
92
+ 1. 已收录宿主映射(81 条;兼容 Vercel Labs `skills` CLI / SkillCat 的公开宿主表,
93
+ 经 0.28.0 逐智能体核实:XDG 解析只覆盖 skills CLI 表确认的 `.config/agents`、
94
+ `.config/devin`、`.config/opencode` 三类 rel,其余 `.config/...` 按字面
95
+ `~/.config` 解析);
68
96
  2. 环境变量覆盖(`CODEX_HOME`、`XDG_CONFIG_HOME`、`DSH_HOME`、
69
97
  `OPENCLAW_STATE_DIR`、`CLAUDE_CONFIG_DIR` 等);
70
98
  3. 常见配置根启发式扫描;
71
- 4. `YOTTA_SKILLS_DISCOVERY_ROOTS` 显式补充;
99
+ 4. `YOTTA_SKILLS_DISCOVERY_ROOTS` 显式补充(自动发现类,需 `--include-discovered`);
72
100
  5. `--dir <path>` 精确指定非标准宿主。
73
101
 
74
102
  自动发现默认排除临时目录、`.bak`、candidate / staging、插件构建目录等噪声。
@@ -27,7 +27,7 @@ import subprocess
27
27
  import sys
28
28
  from pathlib import Path
29
29
 
30
- VERSION = "0.27.0"
30
+ VERSION = "0.28.0"
31
31
  TOOL_NAME = "yotta-skills"
32
32
  CN_NAME = "元阁"
33
33
  MCP_PROTOCOL_MODERN = "2026-07-28"