@yottameta/yotta-skills-plugin 0.28.3 → 0.29.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.
@@ -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([
@@ -103,6 +104,15 @@ function addRoot(list, seen, dir, meta) {
103
104
  if (meta && meta.label && existing.label === '自动发现目录') existing.label = meta.label;
104
105
  if (meta && meta.verified) existing.verified = true;
105
106
  if (meta && meta.bridgeOnly) existing.bridgeOnly = true;
107
+ // 用户注册优先:覆盖分类与手动标记(0.29.0 F2/F3)。
108
+ if (meta && meta.registry) {
109
+ existing.registry = true;
110
+ existing.verified = true;
111
+ existing.detection = 'user-registry';
112
+ if (meta.label) existing.label = meta.label;
113
+ if (meta.agentId) existing.agentId = meta.agentId;
114
+ if (meta.manualState) existing.manualState = meta.manualState;
115
+ }
106
116
  return;
107
117
  }
108
118
  const record = {
@@ -115,6 +125,8 @@ function addRoot(list, seen, dir, meta) {
115
125
  bridgeOnly: Boolean(meta && meta.bridgeOnly),
116
126
  detection: (meta && meta.detection) || 'mapping',
117
127
  exists: isDirectory(resolved),
128
+ registry: Boolean(meta && meta.registry),
129
+ manualState: (meta && meta.manualState) || null,
118
130
  };
119
131
  record.skillCount = record.exists ? countSkills(resolved) : 0;
120
132
  record.status = record.exists ? 'skills-dir' : 'missing';
@@ -223,23 +235,65 @@ function installedMarkers(options) {
223
235
  env['ProgramFiles(x86)'],
224
236
  ];
225
237
  for (const root of roots) collectMarkerNames(root, 2, names, 800);
226
- return [...names].filter((name) => AGENT_HINT.test(name) && !INSTALLED_NOISE.test(name));
238
+ // 标记过滤:已收录宿主的 label 双向匹配 + 启发式关键词;排除卸载器 / 运行时噪音。
239
+ return [...names].filter((name) =>
240
+ (knownLabelMatches(name) || AGENT_HINT.test(name)) && !INSTALLED_NOISE.test(name));
241
+ }
242
+
243
+ function normalizeLabel(value) {
244
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
227
245
  }
228
246
 
247
+ /** 标记名与已收录宿主 label 的双向词边界包含("Trae" ↔ "Trae Code CLI")。 */
229
248
  function knownLabelMatches(name) {
230
- const normalized = String(name || '').toLowerCase();
249
+ const normalized = normalizeLabel(name);
250
+ if (!normalized) return false;
231
251
  for (const info of Object.values(agentDirs.AGENT_DIRS)) {
232
- if (normalized.includes(String(info.label).toLowerCase())) return true;
252
+ const label = normalizeLabel(info.label);
253
+ if (!label) continue;
254
+ if (normalized === label) return true;
255
+ if (normalized.startsWith(label + ' ') || normalized.endsWith(' ' + label) || normalized.includes(' ' + label + ' ')) return true;
256
+ if (label.startsWith(normalized + ' ') || label.endsWith(' ' + normalized) || label.includes(' ' + normalized + ' ')) return true;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ /**
262
+ * 应用标记与宿主匹配:label / agentId 与标记名按词边界双向包含。
263
+ * 例:"Claude" 匹配 "Claude Code";"Trae" 匹配 "Trae Code CLI";
264
+ * 避免 "Pi" 误配 "Pilot" 这类裸子串。
265
+ */
266
+ function markerMatchesHost(markerName, host) {
267
+ const marker = normalizeLabel(markerName);
268
+ if (!marker) return false;
269
+ const candidates = [host && host.label, host && host.agentId].filter(Boolean).map(normalizeLabel);
270
+ for (const candidate of candidates) {
271
+ if (!candidate) continue;
272
+ if (marker === candidate) return true;
273
+ if (marker.startsWith(candidate + ' ') || marker.endsWith(' ' + candidate) || marker.includes(' ' + candidate + ' ')) return true;
274
+ if (candidate.startsWith(marker + ' ') || candidate.endsWith(' ' + marker) || candidate.includes(' ' + marker + ' ')) return true;
233
275
  }
234
276
  return false;
235
277
  }
236
278
 
279
+ /** F3 状态判定:目录 × 实体证据(应用标记 / 用户注册 / 手动标记)。 */
280
+ function judgeHostState(host) {
281
+ if (host.manualState) return host.manualState; // 手动覆盖(available / orphan / ignored)
282
+ const entity = Boolean(host.markerEvidence || (host.evidence && host.evidence.registry));
283
+ if (host.exists && entity) return 'available';
284
+ if (host.exists) return 'orphan';
285
+ if (entity) return 'marker-only';
286
+ return 'missing';
287
+ }
288
+
237
289
  /**
238
290
  * Discover hosts and their skill directories.
239
291
  *
240
292
  * Returns:
241
293
  * hosts - all candidate skill roots (existing and missing known mappings)
242
- * installed - installed app markers, including hosts without a skills dir
294
+ * installed - entity evidence (app markers + user registry), including hosts
295
+ * without a skills dir; plain directory existence is NOT an
296
+ * installed marker (0.29.0 F3)
243
297
  * errors - non-fatal discovery errors
244
298
  */
245
299
  function discoverHosts(options) {
@@ -253,6 +307,23 @@ function discoverHosts(options) {
253
307
  for (const root of agentDirs.envRoots(options)) {
254
308
  addRoot(roots, seen, root.dir, { ...root, detection: root.env ? 'env' : 'workspace' });
255
309
  }
310
+ // 合并顺序:内置映射 → 环境变量 → 用户注册表(F2)→ 自动发现;
311
+ // 同 realpath 用户注册优先(分类 = 已核实,detection = user-registry)。
312
+ let registry = { hosts: [] };
313
+ try {
314
+ registry = hostsRegistry.readHostsRegistry(options);
315
+ } catch (_) { /* registry 读取失败不影响发现 */ }
316
+ for (const item of registry.hosts || []) {
317
+ addRoot(roots, seen, item.dir, {
318
+ detection: 'user-registry',
319
+ label: item.label || '自定义宿主',
320
+ agentId: item.agentId || null,
321
+ verified: true,
322
+ known: false,
323
+ registry: true,
324
+ manualState: item.manualState || null,
325
+ });
326
+ }
256
327
  for (const root of commonRoots(options)) {
257
328
  for (const found of scanForSkillDirs(root.dir, {
258
329
  maxDepth: root.maxDepth,
@@ -265,6 +336,22 @@ function discoverHosts(options) {
265
336
  }
266
337
  }
267
338
 
339
+ // F3:目录证据与实体证据分开记录,判定生命周期状态。
340
+ const markers = installedMarkers(options);
341
+ for (const host of roots) {
342
+ const markerEvidence = markers.some((name) => markerMatchesHost(name, host));
343
+ host.markerEvidence = markerEvidence;
344
+ host.dirEvidence = host.exists;
345
+ host.evidence = {
346
+ dir: host.exists,
347
+ marker: markerEvidence,
348
+ registry: Boolean(host.registry),
349
+ manual: Boolean(host.manualState),
350
+ };
351
+ host.stateSource = host.manualState ? 'manual' : 'auto';
352
+ host.state = judgeHostState(host);
353
+ }
354
+
268
355
  const installed = [];
269
356
  const installedSeen = new Set();
270
357
  const addInstalled = (name, source) => {
@@ -275,13 +362,16 @@ function discoverHosts(options) {
275
362
  installedSeen.add(key);
276
363
  installed.push({ label, source, hasSkillsDir: false });
277
364
  };
278
- for (const name of installedMarkers(options)) {
365
+ for (const name of markers) {
279
366
  if (knownLabelMatches(name) || AGENT_HINT.test(name)) addInstalled(name, '应用标记');
280
367
  }
368
+ for (const host of roots) {
369
+ if (host.detection === 'user-registry') addInstalled(host.label, '用户注册');
370
+ }
281
371
  for (const host of roots) {
282
372
  if (!host.exists) continue;
283
- addInstalled(host.label, host.known ? '已知宿主' : '自动发现');
284
- const item = installed.find((entry) => entry.label.toLowerCase() === String(host.label).toLowerCase());
373
+ if (!host.markerEvidence && host.detection !== 'user-registry') continue;
374
+ const item = installed.find((entry) => markerMatchesHost(entry.label, host));
285
375
  if (item) {
286
376
  item.hasSkillsDir = true;
287
377
  item.skillDir = host.dir;
@@ -303,4 +393,6 @@ module.exports = {
303
393
  discoverHosts,
304
394
  scanForSkillDirs,
305
395
  countSkills,
396
+ markerMatchesHost,
397
+ judgeHostState,
306
398
  };
@@ -44,6 +44,10 @@ 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 天' },
47
51
  { flag: '--scan', arg: '', what: 'hub adopt 只读预演(默认行为)', when: '先看收编候选与冲突时', caution: '' },
48
52
  { flag: '--apply', arg: '', what: 'hub adopt 执行收编', when: '确认候选后执行时', caution: '执行时逐项运行元信扫描;high / critical 默认阻断' },
49
53
  { flag: '--in-place', arg: '', what: 'hub adopt 原地登记,不复制', when: '想让 Hub 直接指向原目录时', caution: '原目录删除即断链;默认复制保真更安全' },
@@ -85,6 +89,20 @@ const CLI_HELP_MODEL = [
85
89
  when: '执行前先确认影响面时',
86
90
  options: ['--dry-run'],
87
91
  },
92
+ {
93
+ name: 'install-self',
94
+ usage: 'install-self [--dir <path>] [--dry-run] [--force] [--json]',
95
+ what: '元阁独立安装:把管理引擎装到独立位置(默认 ~/.yottaskills/yotta-skills),与 Hub / 宿主分离',
96
+ when: '想把元阁装成独立 CLI(不依赖任何智能体)时',
97
+ options: ['--dir', '--dry-run', '--force', '--json'],
98
+ },
99
+ {
100
+ name: 'where',
101
+ usage: 'where [--json]',
102
+ what: '查看元阁位置:运行中 CLI / 独立安装目录(标记当前运行项)/ Hub 真源',
103
+ when: '确认元阁装在哪、是否独立安装时',
104
+ options: ['--json', '--hub'],
105
+ },
88
106
  ],
89
107
  },
90
108
  {
@@ -156,7 +174,11 @@ const CLI_HELP_MODEL = [
156
174
  when: '多智能体共享技能、统一管理技能目录时',
157
175
  options: ['--hub', '--all', '--include-discovered', '--dry-run', '--json'],
158
176
  subcommands: [
159
- { name: 'hosts', usage: 'hub hosts [--json]', what: '发现本机已装智能体与技能目录(已核实 / 自动发现 / 桥接)', when: '先看这台机器有哪些宿主目录时', options: ['--json'] },
177
+ { name: 'hosts', usage: 'hub hosts [--json] [--state <state>]', what: '发现宿主与技能目录(可用 / 残留 / 未创建 / 仅标记 / 桥接)', when: '先看这台机器有哪些宿主目录时', options: ['--json', '--state'] },
178
+ { name: 'hosts add', usage: 'hub hosts add <dir> [--label <名称>] [--id <agentId>] [--json]', what: '注册自定义宿主目录(纳入发现 / 分发 / 收编;不创建目录)', when: '宿主未被自动识别或识别错误时', options: ['--label', '--id', '--json'] },
179
+ { name: 'hosts remove', usage: 'hub hosts remove <dir | --id <agentId>> [--purge [--yes]] [--json]', what: '移除注册(绝不删目录);--purge = 清理残留目录(默认预览,--yes 执行)', when: '注销自定义宿主、清理残留目录时', options: ['--id', '--purge', '--yes', '--json'] },
180
+ { name: 'hosts list', usage: 'hub hosts list [--json]', what: '查看自定义宿主注册表(hosts.json)', when: '核对已注册的自定义目录时', options: ['--json'] },
181
+ { name: 'hosts mark', usage: 'hub hosts mark <dir> --state <available|orphan|ignored> [--json]', what: '手动标记宿主状态(可用 / 残留 / 忽略)', when: '实体证据不足需要人工确认时', options: ['--state', '--json'] },
160
182
  { name: 'install', usage: 'hub install [skill...]', what: '把元技能装进 Hub 真源(清单 27 + 特殊家族 5)', when: '初始化 Hub 或补装技能时', options: ['--hub'] },
161
183
  { name: 'update', usage: 'hub update', what: '更新 Hub 内的元技能(非元技能不参与)', when: '日常维护 Hub 真源时', options: ['--hub'] },
162
184
  { 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'] },
@@ -240,6 +262,8 @@ const CLI_HELP_QUICK = [
240
262
  { cmd: 'update', example: 'yotta-skills update --check' },
241
263
  { cmd: 'update', example: 'yotta-skills update --auto' },
242
264
  { cmd: '--dry-run', example: 'yotta-skills install --dry-run --agent codex' },
265
+ { cmd: 'install-self', example: 'yotta-skills install-self' },
266
+ { cmd: 'where', example: 'yotta-skills where' },
243
267
  ],
244
268
  },
245
269
  {
@@ -265,6 +289,12 @@ const CLI_HELP_QUICK = [
265
289
  items: [
266
290
  { cmd: 'hub', example: 'yotta-skills hub' },
267
291
  { cmd: 'hub hosts', example: 'yotta-skills hub hosts' },
292
+ { cmd: 'hub hosts', example: 'yotta-skills hub hosts --state orphan' },
293
+ { cmd: 'hub hosts add', example: 'yotta-skills hub hosts add <技能目录>' },
294
+ { cmd: 'hub hosts list', example: 'yotta-skills hub hosts list' },
295
+ { cmd: 'hub hosts mark', example: 'yotta-skills hub hosts mark <技能目录> --state orphan' },
296
+ { cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录>', note: '只移除注册,不删目录' },
297
+ { cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录> --purge', danger: true, note: '清理残留目录:先预览,--yes 才执行;目录入回收站保留 7 天' },
268
298
  { cmd: 'hub install', example: 'yotta-skills hub install' },
269
299
  { cmd: 'hub update', example: 'yotta-skills hub update' },
270
300
  { 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
+ };
@@ -0,0 +1,197 @@
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
+ };
46
+ }
47
+ } catch (_) { /* missing or corrupt registry falls back to empty */ }
48
+ return { version: HOSTS_REGISTRY_VERSION, hosts: [] };
49
+ }
50
+
51
+ function writeHostsRegistry(opts, registry) {
52
+ const file = hostsRegistryPath(opts);
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ const tmp = file + '.tmp-' + process.pid;
55
+ fs.writeFileSync(tmp, JSON.stringify({
56
+ version: HOSTS_REGISTRY_VERSION,
57
+ hosts: registry.hosts,
58
+ }, null, 2) + '\n', 'utf8');
59
+ fs.renameSync(tmp, file);
60
+ return file;
61
+ }
62
+
63
+ function safeRealpath(target) {
64
+ try {
65
+ return fs.realpathSync.native(target);
66
+ } catch (_) {
67
+ try {
68
+ return fs.realpathSync(target);
69
+ } catch (_) {
70
+ return path.resolve(target);
71
+ }
72
+ }
73
+ }
74
+
75
+ function samePath(left, right) {
76
+ const a = safeRealpath(left);
77
+ const b = safeRealpath(right);
78
+ if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
79
+ return a === b;
80
+ }
81
+
82
+ function isInside(root, target) {
83
+ const relative = path.relative(path.resolve(root), path.resolve(target));
84
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
85
+ }
86
+
87
+ /** fail-closed 校验:返回中文错误或 null。 */
88
+ function validateHostDir(dir, opts) {
89
+ const target = path.resolve(dir);
90
+ let stat;
91
+ try {
92
+ stat = fs.statSync(target);
93
+ } catch (_) {
94
+ return '目录不存在:' + target;
95
+ }
96
+ if (!stat.isDirectory()) return '不是目录:' + target;
97
+ const hubDir = hubLib.resolveHubDir(opts);
98
+ if (samePath(target, hubDir) || isInside(hubDir, target)) {
99
+ return '指向 Hub 自身或子目录,不允许注册:' + target;
100
+ }
101
+ if (agentDirsLib.isBridgeOnlyDir(target, opts)) {
102
+ return '锁 / 数据桥接目录不允许注册:' + target;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function validateLabel(label) {
108
+ const value = String(label || '').trim();
109
+ if (!value) return 'label 不能为空';
110
+ if (/[\u0000-\u001f\u007f]/.test(value)) return 'label 含控制字符';
111
+ if (value.length > LABEL_MAX_LENGTH) return 'label 过长(最多 ' + LABEL_MAX_LENGTH + ' 字符)';
112
+ return null;
113
+ }
114
+
115
+ /** 注册自定义宿主目录(fail-closed;不创建目录、不改宿主配置)。 */
116
+ function addHost(opts, input) {
117
+ const options = opts || {};
118
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
119
+ if (!dir) return { ok: false, error: '缺少目录:hub hosts add <dir>' };
120
+ const guard = validateHostDir(dir, options);
121
+ if (guard) return { ok: false, error: guard };
122
+ const label = String((input && input.label) || path.basename(dir)).trim();
123
+ const labelError = validateLabel(label);
124
+ if (labelError) return { ok: false, error: labelError };
125
+ const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : null;
126
+
127
+ const registry = readHostsRegistry(options);
128
+ const existing = registry.hosts.find((item) => samePath(item.dir, dir));
129
+ if (existing) {
130
+ return { ok: false, error: '该目录已注册:' + dir, existing };
131
+ }
132
+ const entry = {
133
+ dir,
134
+ label,
135
+ agentId,
136
+ scope: 'verified',
137
+ note: (input && input.note) || null,
138
+ addedAt: nowIso(),
139
+ source: 'manual',
140
+ };
141
+ registry.hosts.push(entry);
142
+ writeHostsRegistry(options, registry);
143
+ return { ok: true, entry, registry };
144
+ }
145
+
146
+ /** 移除注册(只移除登记,绝不删除目录)。 */
147
+ function removeHost(opts, target) {
148
+ const options = opts || {};
149
+ const registry = readHostsRegistry(options);
150
+ const index = registry.hosts.findIndex((item) =>
151
+ (target && target.dir && samePath(item.dir, target.dir)) ||
152
+ (target && target.id && String(item.agentId || '').toLowerCase() === String(target.id).toLowerCase()));
153
+ if (index < 0) return { ok: false, error: '未找到注册项' };
154
+ const removed = registry.hosts.splice(index, 1)[0];
155
+ writeHostsRegistry(options, registry);
156
+ return { ok: true, removed, registry };
157
+ }
158
+
159
+ /** 手动标记宿主状态(可用 / 残留 / 忽略);条目不存在时先注册。 */
160
+ function markHost(opts, input) {
161
+ const options = opts || {};
162
+ const state = input && input.state ? String(input.state).toLowerCase() : null;
163
+ if (!MANUAL_STATES.includes(state)) {
164
+ return { ok: false, error: '未知手动状态:' + state + '(可用 available / 残留 orphan / 忽略 ignored)' };
165
+ }
166
+ const dir = input && input.dir ? path.resolve(input.dir) : null;
167
+ if (!dir) return { ok: false, error: '缺少目录' };
168
+ const registry = readHostsRegistry(options);
169
+ let entry = registry.hosts.find((item) => samePath(item.dir, dir));
170
+ if (!entry) {
171
+ const added = addHost(options, { dir, label: input.label });
172
+ if (!added.ok) return added;
173
+ const refreshed = readHostsRegistry(options);
174
+ entry = refreshed.hosts.find((item) => samePath(item.dir, dir));
175
+ entry.manualState = state;
176
+ writeHostsRegistry(options, refreshed);
177
+ return { ok: true, entry, registry: refreshed };
178
+ }
179
+ entry.manualState = state;
180
+ writeHostsRegistry(options, registry);
181
+ return { ok: true, entry, registry };
182
+ }
183
+
184
+ module.exports = {
185
+ HOSTS_REGISTRY_VERSION,
186
+ MANUAL_STATES,
187
+ LABEL_MAX_LENGTH,
188
+ resolveSkillsRoot,
189
+ hostsRegistryPath,
190
+ readHostsRegistry,
191
+ writeHostsRegistry,
192
+ validateHostDir,
193
+ validateLabel,
194
+ addHost,
195
+ removeHost,
196
+ markHost,
197
+ };