@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.
- 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 +22 -10
- package/skills/yotta-skills/assets/view.html +10 -5
- package/skills/yotta-skills/bin/yotta-skills.js +75 -19
- package/skills/yotta-skills/lib/agent-dirs.js +71 -14
- package/skills/yotta-skills/lib/agent-discovery.js +6 -2
- package/skills/yotta-skills/lib/hub-view-server.js +9 -0
- package/skills/yotta-skills/lib/hub.js +221 -10
- package/skills/yotta-skills/references/hub.md +32 -4
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
|
@@ -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', '.
|
|
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
|
|
123
|
-
|
|
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({
|
|
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,
|
|
180
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
646
|
-
moved.push({
|
|
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
|
-
|
|
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
|
-
#
|
|
75
|
+
# 分发到全部已核实宿主(默认范围):元技能链接时收敛旧副本(回收站 7 天);
|
|
54
76
|
# 外部技能同名真目录默认跳过(--force 才备份并替换)
|
|
55
77
|
npx -y @yottameta/yotta-skills hub link --all
|
|
56
78
|
|
|
57
|
-
#
|
|
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.
|
|
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、插件构建目录等噪声。
|