@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.
@@ -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() {
@@ -219,6 +221,14 @@ function hostsPayload(ctx) {
219
221
  installedMarks: found.installed.length,
220
222
  links: links.length,
221
223
  brokenLinks: links.filter((link) => link.status !== 'ok').length,
224
+ // 0.29.0 F3:宿主生命周期状态细分(面板分组 / 筛选)。
225
+ states: {
226
+ available: hosts.filter((host) => host.state === 'available').length,
227
+ orphan: hosts.filter((host) => host.state === 'orphan').length,
228
+ missing: hosts.filter((host) => host.state === 'missing').length,
229
+ markerOnly: hosts.filter((host) => host.state === 'marker-only').length,
230
+ ignored: hosts.filter((host) => host.state === 'ignored').length,
231
+ },
222
232
  },
223
233
  };
224
234
  }
@@ -525,6 +535,74 @@ function writeRollback(ctx, body) {
525
535
  return { code: result.error ? (result.code || 400) : 200, payload: result };
526
536
  }
527
537
 
538
+ // ── 宿主注册与残留清理(0.29.0 F2/F3) ─────────────────────────────────────
539
+ function auditViewHostEvent(ctx, entry) {
540
+ try {
541
+ if (fs.existsSync(ctx.hubDir)) hubLib.appendAudit(ctx.hubDir, entry);
542
+ } catch (_) { /* 审计失败不阻断主流程 */ }
543
+ }
544
+
545
+ function writeHostAdd(ctx, body) {
546
+ const dir = String(body.dir || '').trim();
547
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
548
+ const label = String(body.label || '').trim();
549
+ const result = hostsRegistryLib.addHost(
550
+ { homeDir: ctx.homeDir, env: ctx.env },
551
+ { dir, label: label || undefined },
552
+ );
553
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
554
+ auditViewHostEvent(ctx, {
555
+ event: 'hosts.add',
556
+ dir: result.entry.dir,
557
+ label: result.entry.label,
558
+ agentId: result.entry.agentId,
559
+ via: 'view',
560
+ });
561
+ return { code: 200, payload: { action: 'hosts.add', entry: result.entry } };
562
+ }
563
+
564
+ function writeHostRemove(ctx, body) {
565
+ const dir = String(body.dir || '').trim();
566
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
567
+ const result = hostsRegistryLib.removeHost({ homeDir: ctx.homeDir, env: ctx.env }, { dir });
568
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
569
+ auditViewHostEvent(ctx, {
570
+ event: 'hosts.remove',
571
+ dir: result.removed.dir,
572
+ label: result.removed.label,
573
+ agentId: result.removed.agentId,
574
+ via: 'view',
575
+ });
576
+ return { code: 200, payload: { action: 'hosts.remove', removed: result.removed } };
577
+ }
578
+
579
+ function writeHostPurge(ctx, body) {
580
+ if (String(body.confirm || '') !== CONFIRM.purge) {
581
+ return { code: 400, payload: { error: '破坏性操作确认不匹配;请确认后再执行。' } };
582
+ }
583
+ const dir = String(body.dir || '').trim();
584
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
585
+ const result = hostPurgeLib.applyHostPurge({
586
+ hubDir: ctx.hubDir,
587
+ dir,
588
+ homeDir: ctx.homeDir,
589
+ env: ctx.env,
590
+ });
591
+ if (!result.ok) return { code: result.code || 400, payload: { error: result.error, state: result.state || null } };
592
+ return {
593
+ code: 200,
594
+ payload: {
595
+ action: 'hosts.purge',
596
+ dir: result.dir,
597
+ state: result.state,
598
+ unlinkCount: result.unlinkCount,
599
+ keptLinks: result.keptLinks,
600
+ keptEntries: result.keptEntries,
601
+ trashedTo: result.trashedTo,
602
+ },
603
+ };
604
+ }
605
+
528
606
  function json(res, code, value) {
529
607
  if (res.writableEnded) return;
530
608
  res.writeHead(code, {
@@ -632,6 +710,25 @@ function handleGet(ctx, pathname, url, res) {
632
710
  return json(res, 200, removePlanPayload(ctx, slug));
633
711
  }
634
712
  if (pathname === '/api/hosts') return json(res, 200, hostsPayload(ctx));
713
+ if (pathname === '/api/hosts/purge-plan') {
714
+ const dir = url.searchParams.get('dir') || '';
715
+ if (!dir) return json(res, 400, { error: '缺少 dir 参数(目标宿主目录)。' });
716
+ const plan = hostPurgeLib.planHostPurge({
717
+ hubDir: ctx.hubDir,
718
+ dir,
719
+ homeDir: ctx.homeDir,
720
+ env: ctx.env,
721
+ });
722
+ if (!plan.ok) return json(res, plan.code || 400, { error: plan.error, state: plan.state || null });
723
+ return json(res, 200, {
724
+ dir: plan.target,
725
+ state: plan.state,
726
+ unlinkCount: plan.linkSlugs.length,
727
+ keptLinks: plan.keptLinks,
728
+ keptEntries: plan.keptEntries,
729
+ trashedTo: plan.trashPath,
730
+ });
731
+ }
635
732
  if (pathname === '/api/adopt/scan') return json(res, 200, adoptScanPayload(ctx));
636
733
  if (pathname === '/api/links') return json(res, 200, linksPayload(ctx));
637
734
  if (pathname === '/api/links/plan') {
@@ -664,6 +761,9 @@ function handlePost(ctx, pathname, req, res) {
664
761
  if (pathname === '/api/links/remove') return writeUnlink(ctx, body);
665
762
  if (pathname === '/api/rollback/apply') return writeRollback(ctx, body);
666
763
  if (pathname === '/api/skills/remove') return writeRemove(ctx, body);
764
+ if (pathname === '/api/hosts/add') return writeHostAdd(ctx, body);
765
+ if (pathname === '/api/hosts/remove') return writeHostRemove(ctx, body);
766
+ if (pathname === '/api/hosts/purge') return writeHostPurge(ctx, body);
667
767
  return { code: 404, payload: { error: 'not found' } };
668
768
  };
669
769
  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 {
@@ -86,6 +86,25 @@ function createInstaller(deps) {
86
86
  if (!opts.force && existingVersion === skill.version) {
87
87
  return { skill, status: 'skip', version: existingVersion, note: '已是最新', exitCode: 0 };
88
88
  }
89
+ if (opts.dryRun) {
90
+ // 0.29.0 D1:只读预览 —— 不发网络请求、不写任何文件(含台账)。
91
+ const latestUnresolved = skill.version === 'latest';
92
+ const planned = existingVersion ? 'update' : 'install';
93
+ return {
94
+ skill,
95
+ status: 'planned',
96
+ planned,
97
+ version: latestUnresolved ? 'latest' : skill.version,
98
+ installedVersion: existingVersion,
99
+ latestUnresolved,
100
+ note: latestUnresolved
101
+ ? 'latest(预览不解析)'
102
+ : planned === 'update'
103
+ ? '将更新 v' + existingVersion + ' -> v' + skill.version
104
+ : '将安装 v' + skill.version,
105
+ exitCode: 0,
106
+ };
107
+ }
89
108
 
90
109
  let tmp = null;
91
110
  let staged = null;
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+ /**
3
+ * 元阁独立安装(install-self)与位置查看(where)—— 0.29.0 F1。
4
+ *
5
+ * 独立安装 = 把管理引擎完整运行件(bin / lib / assets / package.json / skills.json)
6
+ * 装到用户级独立位置(默认 ~/.yottaskills/yotta-skills;YOTTA_SKILLS_HOME 可覆盖根),
7
+ * 写 <root>/self.json 登记。与 Hub 真源、宿主技能池完全独立:不写宿主配置、
8
+ * 不写 mcp.json、不建全局 shim。
9
+ *
10
+ * fail-closed:目标与 Hub 真源重叠 / 锁与数据桥接目录 / 非空陌生目录(--force 才覆盖
11
+ * 同名运行件,无关文件保留)。
12
+ */
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const hubLib = require('./hub');
17
+ const agentDirsLib = require('./agent-dirs');
18
+
19
+ const SELF_REGISTRY_VERSION = 1;
20
+ const RUNTIME_PAYLOAD = ['bin', 'lib', 'assets', 'package.json', 'skills.json'];
21
+ const PACKAGE_NAME = '@yottameta/yotta-skills';
22
+
23
+ function nowIso() {
24
+ return new Date().toISOString();
25
+ }
26
+
27
+ function resolveSelfRoot(opts) {
28
+ const options = opts || {};
29
+ const env = options.env || process.env;
30
+ const explicit = options.selfRoot || env.YOTTA_SKILLS_HOME;
31
+ if (explicit) return path.resolve(explicit);
32
+ return path.join(options.homeDir || os.homedir(), '.yottaskills');
33
+ }
34
+
35
+ function selfRegistryPath(opts) {
36
+ return path.join(resolveSelfRoot(opts), 'self.json');
37
+ }
38
+
39
+ function defaultSelfDir(opts) {
40
+ return path.join(resolveSelfRoot(opts), 'yotta-skills');
41
+ }
42
+
43
+ function readSelfRegistry(opts) {
44
+ try {
45
+ const value = JSON.parse(fs.readFileSync(selfRegistryPath(opts), 'utf8'));
46
+ if (value && typeof value === 'object' && Array.isArray(value.installs)) {
47
+ return {
48
+ version: SELF_REGISTRY_VERSION,
49
+ installs: value.installs.filter((item) => item && typeof item.dir === 'string' && item.dir),
50
+ };
51
+ }
52
+ } catch (_) { /* missing or corrupt registry falls back to empty */ }
53
+ return { version: SELF_REGISTRY_VERSION, installs: [] };
54
+ }
55
+
56
+ function writeSelfRegistry(opts, registry) {
57
+ const file = selfRegistryPath(opts);
58
+ fs.mkdirSync(path.dirname(file), { recursive: true });
59
+ const tmp = file + '.tmp-' + process.pid;
60
+ fs.writeFileSync(tmp, JSON.stringify({
61
+ version: SELF_REGISTRY_VERSION,
62
+ installs: registry.installs,
63
+ }, null, 2) + '\n', 'utf8');
64
+ fs.renameSync(tmp, file);
65
+ return file;
66
+ }
67
+
68
+ function safeRealpath(target) {
69
+ try {
70
+ return fs.realpathSync.native(target);
71
+ } catch (_) {
72
+ try {
73
+ return fs.realpathSync(target);
74
+ } catch (_) {
75
+ return path.resolve(target);
76
+ }
77
+ }
78
+ }
79
+
80
+ function samePath(left, right) {
81
+ const a = safeRealpath(left);
82
+ const b = safeRealpath(right);
83
+ if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
84
+ return a === b;
85
+ }
86
+
87
+ function isInside(root, target) {
88
+ const relative = path.relative(path.resolve(root), path.resolve(target));
89
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
90
+ }
91
+
92
+ function packageRoot() {
93
+ return path.join(__dirname, '..');
94
+ }
95
+
96
+ function readPackageVersion(dir) {
97
+ try {
98
+ return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version || null;
99
+ } catch (_) {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function hasContent(dir) {
105
+ try {
106
+ return fs.readdirSync(dir).length > 0;
107
+ } catch (_) {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /** 目标是否已是元阁运行件目录(允许正常覆盖升级)。 */
113
+ function hasOwnMarker(dir) {
114
+ try {
115
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
116
+ if (pkg && pkg.name === PACKAGE_NAME) return true;
117
+ } catch (_) { /* no readable package.json */ }
118
+ return fs.existsSync(path.join(dir, 'bin', 'yotta-skills.js'));
119
+ }
120
+
121
+ function guardTarget(dir, opts) {
122
+ const hubDir = hubLib.resolveHubDir(opts);
123
+ if (samePath(dir, hubDir) || isInside(hubDir, dir) || isInside(dir, hubDir)) {
124
+ return '目标与 Hub 真源重叠(' + hubDir + '):独立安装与 Hub 必须分离';
125
+ }
126
+ if (agentDirsLib.isBridgeOnlyDir(dir, opts)) {
127
+ return '目标是锁 / 数据桥接目录(XDG_STATE_HOME/skills 或 XDG_DATA_HOME/skills),不允许独立安装';
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function copyEntry(from, to) {
133
+ fs.rmSync(to, { recursive: true, force: true });
134
+ if (fs.statSync(from).isDirectory()) {
135
+ fs.mkdirSync(to, { recursive: true });
136
+ fs.cpSync(from, to, { recursive: true });
137
+ } else {
138
+ fs.mkdirSync(path.dirname(to), { recursive: true });
139
+ fs.copyFileSync(from, to);
140
+ }
141
+ }
142
+
143
+ function installSelf(opts) {
144
+ const options = opts || {};
145
+ const src = packageRoot();
146
+ const dir = path.resolve(options.dir || defaultSelfDir(options));
147
+ const dryRun = Boolean(options.dryRun);
148
+ const force = Boolean(options.force);
149
+ const version = readPackageVersion(src);
150
+ const registryFile = selfRegistryPath(options);
151
+
152
+ const guard = guardTarget(dir, options);
153
+ if (guard) return { ok: false, error: guard, exitCode: 2 };
154
+
155
+ const exists = fs.existsSync(dir);
156
+ const stranger = exists && hasContent(dir) && !hasOwnMarker(dir);
157
+ if (stranger && !force) {
158
+ return {
159
+ ok: false,
160
+ error: '目标是非空陌生目录:' + dir + '(默认拒绝覆盖)',
161
+ hint: '确认目录用途后加 --force 覆盖同名运行件(无关文件保留);或改用 --dir 指定空目录。',
162
+ exitCode: 2,
163
+ };
164
+ }
165
+
166
+ const payload = {
167
+ action: 'install-self',
168
+ dryRun,
169
+ dir,
170
+ version,
171
+ files: RUNTIME_PAYLOAD.slice(),
172
+ registry: registryFile,
173
+ overwrite: exists,
174
+ forced: Boolean(stranger && force),
175
+ exitCode: 0,
176
+ };
177
+ if (dryRun) return { ok: true, ...payload };
178
+
179
+ for (const name of RUNTIME_PAYLOAD) {
180
+ copyEntry(path.join(src, name), path.join(dir, name));
181
+ }
182
+
183
+ const registry = readSelfRegistry(options);
184
+ const entry = { dir, version, installedAt: nowIso(), source: src };
185
+ const index = registry.installs.findIndex((item) => samePath(item.dir, dir));
186
+ if (index >= 0) registry.installs[index] = entry;
187
+ else registry.installs.push(entry);
188
+ writeSelfRegistry(options, registry);
189
+
190
+ return { ok: true, ...payload, installs: registry.installs };
191
+ }
192
+
193
+ function whereInfo(opts) {
194
+ const options = opts || {};
195
+ const running = safeRealpath(process.argv[1] || '');
196
+ const registry = readSelfRegistry(options);
197
+ return {
198
+ action: 'where',
199
+ running,
200
+ version: readPackageVersion(packageRoot()),
201
+ defaultDir: defaultSelfDir(options),
202
+ hubDir: hubLib.resolveHubDir(options),
203
+ registry: selfRegistryPath(options),
204
+ installs: registry.installs.map((item) => ({
205
+ dir: item.dir,
206
+ version: item.version || null,
207
+ installedAt: item.installedAt || null,
208
+ source: item.source || null,
209
+ current: running ? isInside(item.dir, running) : false,
210
+ })),
211
+ };
212
+ }
213
+
214
+ module.exports = {
215
+ SELF_REGISTRY_VERSION,
216
+ RUNTIME_PAYLOAD,
217
+ resolveSelfRoot,
218
+ selfRegistryPath,
219
+ defaultSelfDir,
220
+ readSelfRegistry,
221
+ writeSelfRegistry,
222
+ installSelf,
223
+ whereInfo,
224
+ };
@@ -55,12 +55,48 @@ Hub 真源对清单声明了 `runtimePayload` 的技能(元忆 / 元阁:`bin
55
55
  `hub doctor` 的 `link_scope:*` 检查只读报告「位于默认范围之外」的已建链接,
56
56
  并给出 `hub unlink --dir <目录>` 的清理提示;`hub hosts` 输出三类的本机计数。
57
57
 
58
+ ## 宿主状态细分与自定义注册(0.29.0 起)
59
+
60
+ 每条宿主目录带生命周期状态(`hub hosts` / 面板宿主矩阵):
61
+
62
+ - **可用**:目录存在 + 实体证据(应用标记 / 用户注册 / 手动标记)—— 默认链接 / 分发;
63
+ - **残留**:目录存在 + 无实体证据(卸载残留或 CLI-only 安装)—— 默认保留使用(仍可链接),
64
+ 可显式清理;文案统一「实体未确认」,不判定「已卸载」;
65
+ - **未创建**:目录不存在且无实体证据 —— 安装宿主后自动出现;
66
+ - **仅标记**:已装实体、技能目录未创建 —— `hub link --agent <id>` 会创建目录并链接。
67
+
68
+ `hub doctor` 的 `host_state:*` 检查对残留 / 仅标记给 info 提示,不判 fail。
69
+ 自定义宿主目录可用 `hub hosts add` 注册进发现 / 分发 / 收编范围(只注册,
70
+ 不创建目录、不改宿主配置;移除注册绝不删目录):
71
+
72
+ ```bash
73
+ # 注册 / 查看 / 移除自定义宿主(realpath 去重;Hub / 桥接目录拒绝注册)
74
+ npx -y @yottameta/yotta-skills hub hosts add ~/my-agent/skills --label "我的宿主"
75
+ npx -y @yottameta/yotta-skills hub hosts list
76
+ npx -y @yottameta/yotta-skills hub hosts remove ~/my-agent/skills
77
+
78
+ # 手动标记状态(实体证据不足时):available / orphan / ignored
79
+ npx -y @yottameta/yotta-skills hub hosts mark ~/my-agent/skills --state orphan
80
+
81
+ # 清理残留目录:默认预览;--yes 执行 —— 只删指向 Hub 的链接(含死链),
82
+ # 非 Hub 链接 / 非技能内容随目录保留在回收站(7 天,输出恢复路径)
83
+ npx -y @yottameta/yotta-skills hub hosts remove ~/my-agent/skills --purge
84
+ npx -y @yottameta/yotta-skills hub hosts remove ~/my-agent/skills --purge --yes
85
+ ```
86
+
87
+ 注册表 = `<root>/hosts.json`(root = `YOTTA_SKILLS_HOME` 或 `~/.yottaskills`,schema v1,
88
+ 可 diff);面板「宿主矩阵 → 添加自定义目录」等价可用,写操作需页面令牌与确认串。
89
+
58
90
  ## 命令
59
91
 
60
92
  ```bash
61
93
  # 发现本机已装智能体与技能目录(文件系统优先;不读元忆 / 注册表)
62
94
  npx -y @yottameta/yotta-skills hub hosts
63
95
 
96
+ # 状态过滤 / 自定义注册 / 残留清理(详见「宿主状态细分与自定义注册」节)
97
+ npx -y @yottameta/yotta-skills hub hosts --state orphan
98
+ npx -y @yottameta/yotta-skills hub hosts add ~/my-agent/skills --label "我的宿主"
99
+
64
100
  # 安装 / 更新 Hub 真源
65
101
  # 范围 = 全家清单 27 + 特殊家族 5;特殊家族跟随各自 npm latest
66
102
  # 非元技能不参与更新(来源无统一安装源,用户自行处理)
@@ -198,9 +198,9 @@
198
198
  },
199
199
  "yotta-memory": {
200
200
  "version": "0.22.1",
201
- "treeHash": "sha256:0fb2fa87f85c43a4e6393791944516e3daa5fecbf5bff77648f57eed71aa1ee2",
201
+ "treeHash": "sha256:7ad5ec9cb790bd6f96ecc1977fcc5428fbea9c4d1ebb2b2dd5943e75f2e2a410",
202
202
  "files": 18,
203
- "bytes": 1299467,
203
+ "bytes": 1302396,
204
204
  "mode": "strict",
205
205
  "exceptions": [
206
206
  {
@@ -27,7 +27,7 @@ import subprocess
27
27
  import sys
28
28
  from pathlib import Path
29
29
 
30
- VERSION = "0.28.3"
30
+ VERSION = "0.29.0"
31
31
  TOOL_NAME = "yotta-skills"
32
32
  CN_NAME = "元阁"
33
33
  MCP_PROTOCOL_MODERN = "2026-07-28"