@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.
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+ /**
3
+ * 用户级自定义宿主注册表(0.29.0 F2)—— <root>/hosts.json。
4
+ *
5
+ * root = YOTTA_SKILLS_HOME 或 ~/.yottaskills(与 self.json 同根,独立于 Hub 目录)。
6
+ * schema v1:{ version, hosts:[{ dir, label, agentId, scope:"verified", note,
7
+ * addedAt, source:"manual", manualState? }] }
8
+ *
9
+ * 注册只影响发现 / 分发 / 收编范围:不创建目录、不改宿主配置;
10
+ * 移除注册绝不删除目录。
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
+
18
+ const HOSTS_REGISTRY_VERSION = 1;
19
+ const LABEL_MAX_LENGTH = 48;
20
+ const MANUAL_STATES = ['available', 'orphan', 'ignored'];
21
+
22
+ function nowIso() {
23
+ return new Date().toISOString();
24
+ }
25
+
26
+ function resolveSkillsRoot(opts) {
27
+ const options = opts || {};
28
+ const env = options.env || process.env;
29
+ const explicit = options.skillsRoot || env.YOTTA_SKILLS_HOME;
30
+ if (explicit) return path.resolve(explicit);
31
+ return path.join(options.homeDir || os.homedir(), '.yottaskills');
32
+ }
33
+
34
+ function hostsRegistryPath(opts) {
35
+ return path.join(resolveSkillsRoot(opts), 'hosts.json');
36
+ }
37
+
38
+ function readHostsRegistry(opts) {
39
+ try {
40
+ const value = JSON.parse(fs.readFileSync(hostsRegistryPath(opts), 'utf8'));
41
+ if (value && typeof value === 'object' && Array.isArray(value.hosts)) {
42
+ return {
43
+ version: HOSTS_REGISTRY_VERSION,
44
+ hosts: value.hosts.filter((item) => item && typeof item.dir === 'string' && item.dir),
45
+ // 0.29.1 U2:不接管名单 + 目录覆盖(可选字段,向后兼容旧文件)。
46
+ excluded: Array.isArray(value.excluded)
47
+ ? value.excluded.filter((item) => item && typeof item.value === 'string' && item.value &&
48
+ (item.kind === 'agent' || item.kind === 'dir'))
49
+ : [],
50
+ overrides: Array.isArray(value.overrides)
51
+ ? value.overrides.filter((item) => item && typeof item.agentId === 'string' && item.agentId &&
52
+ typeof item.dir === 'string' && item.dir)
53
+ : [],
54
+ };
55
+ }
56
+ } catch (_) { /* missing or corrupt registry falls back to empty */ }
57
+ return { version: HOSTS_REGISTRY_VERSION, hosts: [], excluded: [], overrides: [] };
58
+ }
59
+
60
+ function writeHostsRegistry(opts, registry) {
61
+ const file = hostsRegistryPath(opts);
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const tmp = file + '.tmp-' + process.pid;
64
+ fs.writeFileSync(tmp, JSON.stringify({
65
+ version: HOSTS_REGISTRY_VERSION,
66
+ hosts: registry.hosts,
67
+ excluded: registry.excluded || [],
68
+ overrides: registry.overrides || [],
69
+ }, null, 2) + '\n', 'utf8');
70
+ fs.renameSync(tmp, file);
71
+ return file;
72
+ }
73
+
74
+ function safeRealpath(target) {
75
+ try {
76
+ return fs.realpathSync.native(target);
77
+ } catch (_) {
78
+ try {
79
+ return fs.realpathSync(target);
80
+ } catch (_) {
81
+ return path.resolve(target);
82
+ }
83
+ }
84
+ }
85
+
86
+ function samePath(left, right) {
87
+ const a = safeRealpath(left);
88
+ const b = safeRealpath(right);
89
+ if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
90
+ return a === b;
91
+ }
92
+
93
+ function isInside(root, target) {
94
+ const relative = path.relative(path.resolve(root), path.resolve(target));
95
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
96
+ }
97
+
98
+ /** YottaCode 目标判定:.yottacode 路径或 YOTTACODE_HOME 子树(0.29.1 U2)。 */
99
+ function isYottaCodeTarget(dir, opts) {
100
+ if (agentDirsLib.isYottaCodeDir(dir)) return true;
101
+ const env = (opts && opts.env) || process.env;
102
+ const ycHome = env.YOTTACODE_HOME ? path.resolve(env.YOTTACODE_HOME) : null;
103
+ return Boolean(ycHome && dir && isInside(ycHome, dir));
104
+ }
105
+
106
+ /** fail-closed 校验:返回中文错误或 null。 */
107
+ function validateHostDir(dir, opts) {
108
+ const target = path.resolve(dir);
109
+ let stat;
110
+ try {
111
+ stat = fs.statSync(target);
112
+ } catch (_) {
113
+ return '目录不存在:' + target;
114
+ }
115
+ if (!stat.isDirectory()) return '不是目录:' + target;
116
+ const hubDir = hubLib.resolveHubDir(opts);
117
+ if (samePath(target, hubDir) || isInside(hubDir, target)) {
118
+ return '指向 Hub 自身或子目录,不允许注册:' + target;
119
+ }
120
+ if (agentDirsLib.isBridgeOnlyDir(target, opts)) {
121
+ return '锁 / 数据桥接目录不允许注册:' + target;
122
+ }
123
+ if (isYottaCodeTarget(target, opts)) {
124
+ return 'YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),不纳入元阁接管:' + target;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function validateLabel(label) {
130
+ const value = String(label || '').trim();
131
+ if (!value) return 'label 不能为空';
132
+ if (/[\u0000-\u001f\u007f]/.test(value)) return 'label 含控制字符';
133
+ if (value.length > LABEL_MAX_LENGTH) return 'label 过长(最多 ' + LABEL_MAX_LENGTH + ' 字符)';
134
+ return null;
135
+ }
136
+
137
+ /** 注册自定义宿主目录(fail-closed;不创建目录、不改宿主配置)。 */
138
+ function addHost(opts, input) {
139
+ const options = opts || {};
140
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
141
+ if (!dir) return { ok: false, error: '缺少目录:hub hosts add <dir>' };
142
+ const guard = validateHostDir(dir, options);
143
+ if (guard) return { ok: false, error: guard };
144
+ const label = String((input && input.label) || path.basename(dir)).trim();
145
+ const labelError = validateLabel(label);
146
+ if (labelError) return { ok: false, error: labelError };
147
+ const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : null;
148
+
149
+ const registry = readHostsRegistry(options);
150
+ const existing = registry.hosts.find((item) => samePath(item.dir, dir));
151
+ if (existing) {
152
+ return { ok: false, error: '该目录已注册:' + dir, existing };
153
+ }
154
+ const entry = {
155
+ dir,
156
+ label,
157
+ agentId,
158
+ scope: 'verified',
159
+ note: (input && input.note) || null,
160
+ addedAt: nowIso(),
161
+ source: 'manual',
162
+ };
163
+ registry.hosts.push(entry);
164
+ writeHostsRegistry(options, registry);
165
+ return { ok: true, entry, registry };
166
+ }
167
+
168
+ /** 移除注册(只移除登记,绝不删除目录)。 */
169
+ function removeHost(opts, target) {
170
+ const options = opts || {};
171
+ const registry = readHostsRegistry(options);
172
+ const index = registry.hosts.findIndex((item) =>
173
+ (target && target.dir && samePath(item.dir, target.dir)) ||
174
+ (target && target.id && String(item.agentId || '').toLowerCase() === String(target.id).toLowerCase()));
175
+ if (index < 0) return { ok: false, error: '未找到注册项' };
176
+ const removed = registry.hosts.splice(index, 1)[0];
177
+ writeHostsRegistry(options, registry);
178
+ return { ok: true, removed, registry };
179
+ }
180
+
181
+ /** 手动标记宿主状态(可用 / 残留 / 忽略);条目不存在时先注册。 */
182
+ function markHost(opts, input) {
183
+ const options = opts || {};
184
+ const state = input && input.state ? String(input.state).toLowerCase() : null;
185
+ if (!MANUAL_STATES.includes(state)) {
186
+ return { ok: false, error: '未知手动状态:' + state + '(可用 available / 残留 orphan / 忽略 ignored)' };
187
+ }
188
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
189
+ if (!dir) return { ok: false, error: '缺少目录' };
190
+ if (isYottaCodeTarget(dir, options)) {
191
+ return { ok: false, error: 'YottaCode 自带三层技能管理,不纳入元阁接管,不能标记:' + dir };
192
+ }
193
+ const registry = readHostsRegistry(options);
194
+ let entry = registry.hosts.find((item) => samePath(item.dir, dir));
195
+ if (!entry) {
196
+ const added = addHost(options, { dir, label: input.label });
197
+ if (!added.ok) return added;
198
+ const refreshed = readHostsRegistry(options);
199
+ entry = refreshed.hosts.find((item) => samePath(item.dir, dir));
200
+ entry.manualState = state;
201
+ writeHostsRegistry(options, refreshed);
202
+ return { ok: true, entry, registry: refreshed };
203
+ }
204
+ entry.manualState = state;
205
+ writeHostsRegistry(options, registry);
206
+ return { ok: true, entry, registry };
207
+ }
208
+
209
+ /** 0.29.1 U2:不接管名单匹配(agentId 或目录);返回命中条目或 null。 */
210
+ function excludedEntryFor(registry, input) {
211
+ const agentId = input && input.agentId ? String(input.agentId).toLowerCase() : null;
212
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
213
+ for (const item of (registry && registry.excluded) || []) {
214
+ if (!item) continue;
215
+ if (item.kind === 'agent' && agentId && String(item.value).toLowerCase() === agentId) return item;
216
+ if (item.kind === 'dir' && dir && samePath(item.value, dir)) return item;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** 目标归类:已收录 agentId -> agent;否则按目录。 */
222
+ function classifyExcludeTarget(target) {
223
+ const value = String(target || '').trim();
224
+ const agentId = value.toLowerCase();
225
+ if (agentDirsLib.AGENT_DIRS[agentId]) return { kind: 'agent', value: agentId };
226
+ return { kind: 'dir', value: path.resolve(value) };
227
+ }
228
+
229
+ /**
230
+ * 加入「不接管」名单(0.29.1 U2):发现 / 显示 / 链接三层全部跳过,
231
+ * `--include-discovered` 也不纳入;幂等。
232
+ */
233
+ function excludeHost(opts, input) {
234
+ const options = opts || {};
235
+ const target = input && input.target ? String(input.target).trim() : '';
236
+ if (!target) return { ok: false, error: '缺少目标:hub hosts exclude <agentId|目录>' };
237
+ const { kind, value } = classifyExcludeTarget(target);
238
+ if (kind === 'dir') {
239
+ if (isYottaCodeTarget(value, options)) {
240
+ return { ok: false, error: 'YottaCode 自带三层技能管理,不纳入元阁接管,无需加入不接管名单:' + value };
241
+ }
242
+ if (agentDirsLib.isBridgeOnlyDir(value, options)) {
243
+ return { ok: false, error: '锁 / 数据桥接目录本就不参与链接,无需加入不接管名单:' + value };
244
+ }
245
+ }
246
+ const registry = readHostsRegistry(options);
247
+ const existing = excludedEntryFor(registry, { agentId: kind === 'agent' ? value : null, dir: kind === 'dir' ? value : null });
248
+ if (existing) return { ok: true, entry: existing, registry, already: true };
249
+ const label = input && input.label
250
+ ? String(input.label).trim()
251
+ : kind === 'agent'
252
+ ? ((agentDirsLib.AGENT_DIRS[value] || {}).label || value)
253
+ : path.basename(value);
254
+ const entry = { kind, value, label, addedAt: nowIso() };
255
+ registry.excluded = (registry.excluded || []).concat([entry]);
256
+ writeHostsRegistry(options, registry);
257
+ return { ok: true, entry, registry };
258
+ }
259
+
260
+ /** 移出「不接管」名单(恢复接管)。 */
261
+ function includeHost(opts, input) {
262
+ const options = opts || {};
263
+ const target = input && input.target ? String(input.target).trim() : '';
264
+ if (!target) return { ok: false, error: '缺少目标:hub hosts include <agentId|目录>' };
265
+ const { kind, value } = classifyExcludeTarget(target);
266
+ const registry = readHostsRegistry(options);
267
+ const index = (registry.excluded || []).findIndex((item) => item.kind === kind &&
268
+ (kind === 'agent' ? String(item.value).toLowerCase() === value : samePath(item.value, value)));
269
+ if (index < 0) return { ok: false, error: '未在不接管名单:' + target };
270
+ const removed = registry.excluded.splice(index, 1)[0];
271
+ writeHostsRegistry(options, registry);
272
+ return { ok: true, removed, registry };
273
+ }
274
+
275
+ /**
276
+ * 目录覆盖(0.29.1 U2):用户级覆盖已收录 agentId 的内置映射目录;
277
+ * 被覆盖的旧目录不再进入默认发现 / 链接。
278
+ */
279
+ function setHostOverride(opts, input) {
280
+ const options = opts || {};
281
+ const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : '';
282
+ if (!agentId) return { ok: false, error: '缺少 agentId:hub hosts set <agentId> --dir <目录>(恢复默认用 --clear)' };
283
+ const info = agentDirsLib.AGENT_DIRS[agentId];
284
+ if (!info) return { ok: false, error: '未收录智能体: ' + agentId };
285
+ if (info.neverLink) return { ok: false, error: 'YottaCode 不纳入元阁接管,不能设置目录覆盖。' };
286
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
287
+ if (!dir) return { ok: false, error: '缺少目录:hub hosts set <agentId> --dir <目录>(恢复默认用 --clear)' };
288
+ const guard = validateHostDir(dir, options);
289
+ if (guard) return { ok: false, error: guard };
290
+ const label = input && input.label ? String(input.label).trim() : info.label;
291
+ const labelError = validateLabel(label);
292
+ if (labelError) return { ok: false, error: labelError };
293
+ const registry = readHostsRegistry(options);
294
+ const entry = {
295
+ agentId,
296
+ dir,
297
+ label,
298
+ previousDirs: (info.dirs || []).map((rel) => agentDirsLib.resolveUserDir(rel, options)),
299
+ addedAt: nowIso(),
300
+ };
301
+ const index = (registry.overrides || []).findIndex((item) => String(item.agentId).toLowerCase() === agentId);
302
+ if (index >= 0) registry.overrides[index] = entry;
303
+ else registry.overrides = (registry.overrides || []).concat([entry]);
304
+ writeHostsRegistry(options, registry);
305
+ return { ok: true, entry, registry };
306
+ }
307
+
308
+ /** 清除目录覆盖(恢复内置映射)。 */
309
+ function clearHostOverride(opts, input) {
310
+ const options = opts || {};
311
+ const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : '';
312
+ if (!agentId) return { ok: false, error: '缺少 agentId:hub hosts set <agentId> --clear' };
313
+ const registry = readHostsRegistry(options);
314
+ const index = (registry.overrides || []).findIndex((item) => String(item.agentId).toLowerCase() === agentId);
315
+ if (index < 0) return { ok: false, error: '未找到目录覆盖:' + agentId };
316
+ const removed = registry.overrides.splice(index, 1)[0];
317
+ writeHostsRegistry(options, registry);
318
+ return { ok: true, removed, registry };
319
+ }
320
+
321
+ module.exports = {
322
+ HOSTS_REGISTRY_VERSION,
323
+ MANUAL_STATES,
324
+ LABEL_MAX_LENGTH,
325
+ resolveSkillsRoot,
326
+ hostsRegistryPath,
327
+ readHostsRegistry,
328
+ writeHostsRegistry,
329
+ validateHostDir,
330
+ validateLabel,
331
+ addHost,
332
+ removeHost,
333
+ markHost,
334
+ excludedEntryFor,
335
+ excludeHost,
336
+ includeHost,
337
+ setHostOverride,
338
+ clearHostOverride,
339
+ };
@@ -33,6 +33,8 @@ const routeDynamicLib = require('./route-dynamic');
33
33
  const providerLib = require('./provider');
34
34
  const usageLib = require('./usage-journal');
35
35
  const cliHelpLib = require('./cli-help');
36
+ const hostsRegistryLib = require('./hosts-registry');
37
+ const hostPurgeLib = require('./host-purge');
36
38
 
37
39
  const DEFAULT_HOST = '127.0.0.1';
38
40
  const DEFAULT_PORT = 8789;
@@ -42,7 +44,7 @@ const DEFAULT_LIMIT = 100;
42
44
  const MAX_LIMIT = 500;
43
45
  const TOKEN_HEADER = 'x-yotta-view-token';
44
46
  const TOKEN_PLACEHOLDER = '__YOTTA_VIEW_TOKEN__';
45
- const CONFIRM = { unlink: 'unlink', rollback: 'rollback', remove: 'remove' };
47
+ const CONFIRM = { unlink: 'unlink', rollback: 'rollback', remove: 'remove', purge: 'purge' };
46
48
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
47
49
 
48
50
  function nowIso() {
@@ -202,12 +204,17 @@ function hostsPayload(ctx) {
202
204
  brokenLinks: hostLinks.filter((link) => link.status !== 'ok').length,
203
205
  };
204
206
  });
207
+ const registry = hostsRegistryLib.readHostsRegistry({ homeDir: ctx.homeDir, env: ctx.env });
205
208
  return {
206
209
  generatedAt: found.generatedAt,
207
210
  hubDir: ctx.hubDir,
208
211
  standard: hubLib.STANDARD_ID,
209
212
  hosts,
210
213
  installed: found.installed,
214
+ // 0.29.1 U2:面板只渲染未配对标记(与 CLI hubHostLines 共用单一真源)。
215
+ unpaired: discoveryLib.unpairedMarkers(found),
216
+ excluded: registry.excluded || [],
217
+ overrides: registry.overrides || [],
211
218
  links,
212
219
  hubSkills,
213
220
  summary: {
@@ -217,8 +224,19 @@ function hostsPayload(ctx) {
217
224
  discovered: hosts.filter((host) => host.exists && !host.verified && !host.bridgeOnly).length,
218
225
  bridge: hosts.filter((host) => host.exists && host.bridgeOnly).length,
219
226
  installedMarks: found.installed.length,
227
+ unpairedMarks: discoveryLib.unpairedMarkers(found).length,
228
+ excluded: (registry.excluded || []).length,
229
+ overrides: (registry.overrides || []).length,
220
230
  links: links.length,
221
231
  brokenLinks: links.filter((link) => link.status !== 'ok').length,
232
+ // 0.29.0 F3:宿主生命周期状态细分(面板分组 / 筛选)。
233
+ states: {
234
+ available: hosts.filter((host) => host.state === 'available').length,
235
+ orphan: hosts.filter((host) => host.state === 'orphan').length,
236
+ missing: hosts.filter((host) => host.state === 'missing').length,
237
+ markerOnly: hosts.filter((host) => host.state === 'marker-only').length,
238
+ ignored: hosts.filter((host) => host.state === 'ignored').length,
239
+ },
222
240
  },
223
241
  };
224
242
  }
@@ -525,6 +543,138 @@ function writeRollback(ctx, body) {
525
543
  return { code: result.error ? (result.code || 400) : 200, payload: result };
526
544
  }
527
545
 
546
+ // ── 宿主注册与残留清理(0.29.0 F2/F3) ─────────────────────────────────────
547
+ function auditViewHostEvent(ctx, entry) {
548
+ try {
549
+ if (fs.existsSync(ctx.hubDir)) hubLib.appendAudit(ctx.hubDir, entry);
550
+ } catch (_) { /* 审计失败不阻断主流程 */ }
551
+ }
552
+
553
+ function writeHostAdd(ctx, body) {
554
+ const dir = String(body.dir || '').trim();
555
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
556
+ const label = String(body.label || '').trim();
557
+ const result = hostsRegistryLib.addHost(
558
+ { homeDir: ctx.homeDir, env: ctx.env },
559
+ { dir, label: label || undefined },
560
+ );
561
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
562
+ auditViewHostEvent(ctx, {
563
+ event: 'hosts.add',
564
+ dir: result.entry.dir,
565
+ label: result.entry.label,
566
+ agentId: result.entry.agentId,
567
+ via: 'view',
568
+ });
569
+ return { code: 200, payload: { action: 'hosts.add', entry: result.entry } };
570
+ }
571
+
572
+ function writeHostRemove(ctx, body) {
573
+ const dir = String(body.dir || '').trim();
574
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
575
+ const result = hostsRegistryLib.removeHost({ homeDir: ctx.homeDir, env: ctx.env }, { dir });
576
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
577
+ auditViewHostEvent(ctx, {
578
+ event: 'hosts.remove',
579
+ dir: result.removed.dir,
580
+ label: result.removed.label,
581
+ agentId: result.removed.agentId,
582
+ via: 'view',
583
+ });
584
+ return { code: 200, payload: { action: 'hosts.remove', removed: result.removed } };
585
+ }
586
+
587
+ function writeHostPurge(ctx, body) {
588
+ if (String(body.confirm || '') !== CONFIRM.purge) {
589
+ return { code: 400, payload: { error: '破坏性操作确认不匹配;请确认后再执行。' } };
590
+ }
591
+ const dir = String(body.dir || '').trim();
592
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
593
+ const result = hostPurgeLib.applyHostPurge({
594
+ hubDir: ctx.hubDir,
595
+ dir,
596
+ homeDir: ctx.homeDir,
597
+ env: ctx.env,
598
+ });
599
+ if (!result.ok) return { code: result.code || 400, payload: { error: result.error, state: result.state || null } };
600
+ return {
601
+ code: 200,
602
+ payload: {
603
+ action: 'hosts.purge',
604
+ dir: result.dir,
605
+ state: result.state,
606
+ unlinkCount: result.unlinkCount,
607
+ keptLinks: result.keptLinks,
608
+ keptEntries: result.keptEntries,
609
+ trashedTo: result.trashedTo,
610
+ },
611
+ };
612
+ }
613
+
614
+ // ── 宿主标记 / 目录覆盖 / 不接管(0.29.1 U2;与 CLI 同源 hosts.json) ─────────
615
+ function writeHostMark(ctx, body) {
616
+ const dir = String(body.dir || '').trim();
617
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
618
+ const state = String(body.state || '').trim().toLowerCase();
619
+ const result = hostsRegistryLib.markHost({ homeDir: ctx.homeDir, env: ctx.env }, { dir, state });
620
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
621
+ auditViewHostEvent(ctx, { event: 'hosts.mark', dir: result.entry.dir, state, via: 'view' });
622
+ return { code: 200, payload: { action: 'hosts.mark', entry: result.entry } };
623
+ }
624
+
625
+ function writeHostSet(ctx, body) {
626
+ const agentId = String(body.agentId || '').trim().toLowerCase();
627
+ if (!agentId) return { code: 400, payload: { error: '缺少 agentId。' } };
628
+ const registryOpts = { homeDir: ctx.homeDir, env: ctx.env };
629
+ if (body.clear) {
630
+ const result = hostsRegistryLib.clearHostOverride(registryOpts, { agentId });
631
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
632
+ auditViewHostEvent(ctx, { event: 'hosts.set', agentId, cleared: true, dir: result.removed.dir, via: 'view' });
633
+ return { code: 200, payload: { action: 'hosts.set', cleared: true, removed: result.removed } };
634
+ }
635
+ const dir = String(body.dir || '').trim();
636
+ const result = hostsRegistryLib.setHostOverride(registryOpts, { agentId, dir, label: body.label });
637
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
638
+ auditViewHostEvent(ctx, {
639
+ event: 'hosts.set',
640
+ agentId,
641
+ dir: result.entry.dir,
642
+ label: result.entry.label,
643
+ via: 'view',
644
+ });
645
+ return { code: 200, payload: { action: 'hosts.set', entry: result.entry } };
646
+ }
647
+
648
+ function writeHostExclude(ctx, body) {
649
+ const target = String(body.target || '').trim();
650
+ if (!target) return { code: 400, payload: { error: '缺少 target(agentId 或目录)。' } };
651
+ const result = hostsRegistryLib.excludeHost({ homeDir: ctx.homeDir, env: ctx.env }, { target });
652
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
653
+ auditViewHostEvent(ctx, {
654
+ event: 'hosts.exclude',
655
+ kind: result.entry.kind,
656
+ value: result.entry.value,
657
+ label: result.entry.label,
658
+ via: 'view',
659
+ });
660
+ return { code: 200, payload: { action: 'hosts.exclude', entry: result.entry, already: Boolean(result.already) } };
661
+ }
662
+
663
+ function writeHostInclude(ctx, body) {
664
+ const target = String(body.target || '').trim();
665
+ if (!target) return { code: 400, payload: { error: '缺少 target(agentId 或目录)。' } };
666
+ const result = hostsRegistryLib.includeHost({ homeDir: ctx.homeDir, env: ctx.env }, { target });
667
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
668
+ auditViewHostEvent(ctx, {
669
+ event: 'hosts.include',
670
+ kind: result.removed.kind,
671
+ value: result.removed.value,
672
+ label: result.removed.label,
673
+ via: 'view',
674
+ });
675
+ return { code: 200, payload: { action: 'hosts.include', removed: result.removed } };
676
+ }
677
+
528
678
  function json(res, code, value) {
529
679
  if (res.writableEnded) return;
530
680
  res.writeHead(code, {
@@ -632,6 +782,25 @@ function handleGet(ctx, pathname, url, res) {
632
782
  return json(res, 200, removePlanPayload(ctx, slug));
633
783
  }
634
784
  if (pathname === '/api/hosts') return json(res, 200, hostsPayload(ctx));
785
+ if (pathname === '/api/hosts/purge-plan') {
786
+ const dir = url.searchParams.get('dir') || '';
787
+ if (!dir) return json(res, 400, { error: '缺少 dir 参数(目标宿主目录)。' });
788
+ const plan = hostPurgeLib.planHostPurge({
789
+ hubDir: ctx.hubDir,
790
+ dir,
791
+ homeDir: ctx.homeDir,
792
+ env: ctx.env,
793
+ });
794
+ if (!plan.ok) return json(res, plan.code || 400, { error: plan.error, state: plan.state || null });
795
+ return json(res, 200, {
796
+ dir: plan.target,
797
+ state: plan.state,
798
+ unlinkCount: plan.linkSlugs.length,
799
+ keptLinks: plan.keptLinks,
800
+ keptEntries: plan.keptEntries,
801
+ trashedTo: plan.trashPath,
802
+ });
803
+ }
635
804
  if (pathname === '/api/adopt/scan') return json(res, 200, adoptScanPayload(ctx));
636
805
  if (pathname === '/api/links') return json(res, 200, linksPayload(ctx));
637
806
  if (pathname === '/api/links/plan') {
@@ -664,6 +833,13 @@ function handlePost(ctx, pathname, req, res) {
664
833
  if (pathname === '/api/links/remove') return writeUnlink(ctx, body);
665
834
  if (pathname === '/api/rollback/apply') return writeRollback(ctx, body);
666
835
  if (pathname === '/api/skills/remove') return writeRemove(ctx, body);
836
+ if (pathname === '/api/hosts/add') return writeHostAdd(ctx, body);
837
+ if (pathname === '/api/hosts/remove') return writeHostRemove(ctx, body);
838
+ if (pathname === '/api/hosts/purge') return writeHostPurge(ctx, body);
839
+ if (pathname === '/api/hosts/mark') return writeHostMark(ctx, body);
840
+ if (pathname === '/api/hosts/set') return writeHostSet(ctx, body);
841
+ if (pathname === '/api/hosts/exclude') return writeHostExclude(ctx, body);
842
+ if (pathname === '/api/hosts/include') return writeHostInclude(ctx, body);
667
843
  return { code: 404, payload: { error: 'not found' } };
668
844
  };
669
845
  ctx.queue(run)
@@ -1461,6 +1461,22 @@ function doctor(options) {
1461
1461
  singleSourceChecks(opts, add);
1462
1462
  } catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
1463
1463
 
1464
+ // F3(0.29.0):宿主状态细分只读检查 —— 残留 / 仅标记给 info 提示,不 fail。
1465
+ if (opts.discovery) {
1466
+ for (const host of opts.discovery.hosts || []) {
1467
+ if (!host || !host.dir) continue;
1468
+ if (host.state === 'orphan') {
1469
+ add('host_state:' + pathKey(host.dir), true, 'info',
1470
+ '宿主目录疑似残留(实体未确认): ' + host.label + ' — ' + host.dir,
1471
+ '确认宿主已卸载后可清理:yotta-skills hub hosts remove "' + host.dir + '" --purge(默认预览,--yes 执行)');
1472
+ } else if (host.state === 'marker-only') {
1473
+ add('host_state:' + pathKey(host.dir), true, 'info',
1474
+ '已装实体但技能目录未创建: ' + host.label,
1475
+ '需要分发时运行 yotta-skills hub link --agent ' + (host.agentId || '<id>'));
1476
+ }
1477
+ }
1478
+ }
1479
+
1464
1480
  const errors = checks.filter((check) => !check.ok && check.severity === 'error');
1465
1481
  const warnings = checks.filter((check) => !check.ok && check.severity === 'warning');
1466
1482
  return {
@@ -8,6 +8,7 @@ const lifecycleLib = require('./install-lifecycle');
8
8
  const healthLib = require('./install-health');
9
9
  const snapshotLib = require('./install-snapshot');
10
10
  const hookAdapterLib = require('./hook-adapter');
11
+ const scanLib = require('./skills-scan');
11
12
 
12
13
  function nowTag() {
13
14
  return new Date().toISOString().replace(/[:.]/g, '-');
@@ -60,6 +61,15 @@ function restoreBackup(target, backup, hadTarget) {
60
61
  return false;
61
62
  }
62
63
 
64
+ /** 0.29.1 U1「只升不降」跳过原因文案(equal / local-ahead / unknown)。 */
65
+ function versionSkipNote(relation, installed, target) {
66
+ if (relation === 'equal') return '已是最新';
67
+ if (relation === 'local-ahead') {
68
+ return '本地领先 v' + installed + ' > 目标 v' + target + ',保留不降级(--force 可强制)';
69
+ }
70
+ return '无法比较版本(本地 v' + installed + ' / 目标 v' + target + '),保留(--force 可强制)';
71
+ }
72
+
63
73
  function createInstaller(deps) {
64
74
  const homeDir = deps.homeDir || os.homedir();
65
75
  const runPhase = deps.runPhase || lifecycleLib.runPhase;
@@ -83,8 +93,37 @@ function createInstaller(deps) {
83
93
  return function installOne(skill, dest, opts) {
84
94
  const target = path.join(dest, skill.slug);
85
95
  const existingVersion = deps.readInstalledVersion(target);
86
- if (!opts.force && existingVersion === skill.version) {
87
- return { skill, status: 'skip', version: existingVersion, note: '已是最新', exitCode: 0 };
96
+ // 0.29.1 U1「只升不降」:pin 模式目标即最终版本,入口即可判定;
97
+ // range / latest 模式(目标非最终版本)在 npm pack 解析出精确版本后复判(见下方)。
98
+ const relation = scanLib.versionRelation(existingVersion, skill.version);
99
+ if (!opts.force && opts.pin !== false &&
100
+ (relation === 'equal' || relation === 'local-ahead' || relation === 'unknown')) {
101
+ return {
102
+ skill,
103
+ status: 'skip',
104
+ version: existingVersion,
105
+ note: versionSkipNote(relation, existingVersion, skill.version),
106
+ exitCode: 0,
107
+ };
108
+ }
109
+ if (opts.dryRun) {
110
+ // 0.29.0 D1:只读预览 —— 不发网络请求、不写任何文件(含台账)。
111
+ const latestUnresolved = skill.version === 'latest';
112
+ const planned = existingVersion ? 'update' : 'install';
113
+ return {
114
+ skill,
115
+ status: 'planned',
116
+ planned,
117
+ version: latestUnresolved ? 'latest' : skill.version,
118
+ installedVersion: existingVersion,
119
+ latestUnresolved,
120
+ note: latestUnresolved
121
+ ? 'latest(预览不解析)'
122
+ : planned === 'update'
123
+ ? '将更新 v' + existingVersion + ' -> v' + skill.version
124
+ : '将安装 v' + skill.version,
125
+ exitCode: 0,
126
+ };
88
127
  }
89
128
 
90
129
  let tmp = null;
@@ -105,6 +144,23 @@ function createInstaller(deps) {
105
144
  return { skill, status: 'fail', version: null, note: packed.error, exitCode: 1 };
106
145
  }
107
146
 
147
+ // 0.29.1 U1:解析出精确版本后、任何解压 / 快照 / 写入之前复判(latest / range 路径)。
148
+ const resolvedVersion = packed.resolved || (skill.version === 'latest' ? null : skill.version);
149
+ if (!opts.force && existingVersion) {
150
+ const resolvedRelation = resolvedVersion
151
+ ? scanLib.versionRelation(existingVersion, resolvedVersion)
152
+ : 'unknown';
153
+ if (resolvedRelation === 'equal' || resolvedRelation === 'local-ahead' || resolvedRelation === 'unknown') {
154
+ return {
155
+ skill,
156
+ status: 'skip',
157
+ version: existingVersion,
158
+ note: versionSkipNote(resolvedRelation, existingVersion, resolvedVersion || skill.version),
159
+ exitCode: 0,
160
+ };
161
+ }
162
+ }
163
+
108
164
  const extractDir = path.join(tmp, 'extract');
109
165
  fs.mkdirSync(extractDir, { recursive: true });
110
166
  extracted = deps.extractTarball(packed.tarball, extractDir);