@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.
@@ -49,17 +49,21 @@ const agentDirsLib = require('../lib/agent-dirs');
49
49
  const agentDiscoveryLib = require('../lib/agent-discovery');
50
50
  const hubLib = require('../lib/hub');
51
51
  const hubAdoptLib = require('../lib/hub-adopt');
52
+ const selfInstallLib = require('../lib/self-install');
53
+ const hostsRegistryLib = require('../lib/hosts-registry');
54
+ const hostPurgeLib = require('../lib/host-purge');
52
55
  const hubScanLib = require('../lib/hub-scan');
53
56
  const hubViewServerLib = require('../lib/hub-view-server');
54
57
  const { createInstaller, isSafeTarEntry } = require('../lib/install-pipeline');
55
58
  const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
59
+ const { versionRelation } = require('../lib/skills-scan');
56
60
 
57
61
  const PKG_ROOT = path.join(__dirname, '..');
58
- let VERSION = '0.28.3';
62
+ let VERSION = '0.29.1';
59
63
  try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
60
64
 
61
65
  // @generated view-html:start
62
- const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .loadbar{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px;padding:10px 13px;border:1px dashed var(--line2);border-radius:8px;margin:0 0 14px;background:var(--panel)}\n .spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--acc);animation:spin .8s linear infinite;flex:none}\n @keyframes spin{to{transform:rotate(360deg)}}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n .cmd-detail{border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:8px;padding:0 11px;min-width:0}\n .cmd-detail summary{cursor:pointer;padding:9px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}\n .cmd-detail .note{padding:0 0 8px}\n .cmd-detail .cmd{margin:0 0 8px}\n\n @media (max-width:980px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:13px;gap:11px}\n .nav{flex-direction:row;overflow-x:auto;width:100%;scrollbar-width:none;-webkit-overflow-scrolling:touch}\n .nav::-webkit-scrollbar{display:none}\n .nav button{white-space:nowrap;padding:7px 9px}\n .side-foot{margin-top:0;flex-direction:row;align-items:center}\n .privacy{display:none}\n .ghost-btn{flex:1;white-space:nowrap}\n .verline{white-space:nowrap;text-align:right}\n .main{padding:16px 14px 56px}\n .g4,.g2{grid-template-columns:minmax(0,1fr)}\n .search{min-width:100%}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}\n }\n</style>\n</head>\n<body>\n<div class=\"shell\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3 4 7v4c0 4.4 3.4 8.4 8 10 4.6-1.6 8-5.6 8-10V7z\"/><path d=\"M9 12l2 2 4-4\"/></svg></div>\n <div><div class=\"brand-name\">元阁 · 技能枢纽</div><div class=\"brand-sub\">Hub 本地面板</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"hosts\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"13\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 7.5h.01M7 16.5h.01\"/></svg>宿主矩阵</button>\n <button data-view=\"adopt\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3v12\"/><path d=\"m7 10 5 5 5-5\"/><path d=\"M4 21h16\"/></svg>收编向导</button>\n <button data-view=\"links\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1L11 4.9\"/><path d=\"M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1-1\"/></svg>链接与体检</button>\n <button data-view=\"records\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/><path d=\"M12 8v5l3 2\"/></svg>记录与回滚</button>\n <button data-view=\"route\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"18\" cy=\"18\" r=\"2.5\"/><path d=\"M8.5 6H15a3 3 0 0 1 0 6h-6a3 3 0 0 0 0 6h6.5\"/></svg>路由与编排</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>高级 CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>本机运行 · 零远程<br>写操作需页面令牌与确认</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n <div class=\"verline\" id=\"verLine\"></div>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">Hub 里有什么、健康吗、最近做了什么</div></div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"refreshBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>刷新</button>\n <div class=\"chip\"><span class=\"dot\"></span>本机 · 127.0.0.1</div>\n </div>\n <div class=\"loadbar\" id=\"loadbar\" hidden><span class=\"spinner\"></span><span id=\"loadbarText\">正在加载…</span></div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\" id=\"ovCards\"></div>\n <div class=\"section\" id=\"ovDoctor\"></div>\n <div class=\"section grid g2\">\n <div class=\"panel\"><h3>Hub 内容</h3><div class=\"faint\">技能真源在 Hub 目录,宿主目录通过链接分发。</div>\n <label class=\"search section\" style=\"min-width:100%\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"ovSkillFilter\" placeholder=\"过滤技能(输入后显示全部匹配)\"></label>\n <div class=\"list\" id=\"ovSkills\"></div>\n </div>\n <div class=\"panel\"><h3>最近动作</h3><div class=\"faint\">收编 / 链接 / 解除都写入 Hub 审计。</div><div class=\"list\" id=\"ovAudit\"></div></div>\n </div>\n <div class=\"section panel\">\n <h3>Hub 位置</h3>\n <div class=\"mono\" id=\"ovHubDir\">-</div>\n <div class=\"toolbar section\">\n <button class=\"btn small\" data-goto=\"adopt\">去收编</button>\n <button class=\"btn small\" data-goto=\"links\">去链接与体检</button>\n <button class=\"btn small\" data-goto=\"records\">查看记录</button>\n <button class=\"btn small\" id=\"copyHubDir\">复制路径</button>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-hosts\">\n <div class=\"toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"hostSearch\" placeholder=\"过滤宿主 / 目录\"></label>\n <span class=\"label\" id=\"hostSummary\"></span>\n </div>\n <div class=\"section panel\">\n <table><thead><tr><th>宿主</th><th>技能目录</th><th>范围</th><th>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);默认范围 = 已核实宿主,自动发现目录需在 CLI 用 --include-discovered 显式纳入,锁 / 数据桥接目录永不链接;元技能链接时收敛旧副本(移入回收站,保留 7 天可恢复)。</div>\n <div class=\"list\" id=\"linkTargets\"></div>\n </div>\n <div class=\"section panel\">\n <h3>当前链接</h3>\n <table><thead><tr><th>技能</th><th>宿主</th><th>目标</th><th>状态</th><th></th></tr></thead><tbody id=\"linkRows\"></tbody></table>\n <div class=\"empty\" id=\"linkEmpty\" hidden>还没有任何链接。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-records\">\n <div class=\"toolbar\">\n <button class=\"btn small\" data-tab=\"snapshots\">回滚快照</button>\n <button class=\"btn small\" data-tab=\"evidence\">安装 / 回滚证据</button>\n <button class=\"btn small\" data-tab=\"audit\">Hub 审计</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"recordsInfo\"></span>\n </div>\n <div class=\"section panel\" id=\"recSnapshots\">\n <table><thead><tr><th>技能</th><th>版本</th><th>时间</th><th>来源目录</th><th>状态</th><th></th></tr></thead><tbody id=\"snapshotRows\"></tbody></table>\n <div class=\"empty\" id=\"snapshotEmpty\" hidden>没有安装快照。快照由 install / update 写入。</div>\n </div>\n <div class=\"section panel\" id=\"recEvidence\" hidden>\n <div class=\"list\" id=\"evidenceRows\"></div>\n <div class=\"empty\" id=\"evidenceEmpty\" hidden>没有证据记录。</div>\n </div>\n <div class=\"section panel\" id=\"recAudit\" hidden>\n <div class=\"list\" id=\"auditRows\"></div>\n <div class=\"empty\" id=\"auditEmpty\" hidden>没有 Hub 审计记录。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-route\">\n <div class=\"panel\">\n <h3>一句话需求 → 技能组合</h3>\n <div class=\"faint\">只读调用静态 playbook(7 组)+ 动态路由块;安装建议只给命令,不在网页执行。</div>\n <div class=\"toolbar section\">\n <label class=\"search\" style=\"flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"routeInput\" placeholder=\"例如:帮我做发布前质量检查\"></label>\n <button class=\"btn primary\" id=\"routeBtn\">给出组合</button>\n </div>\n </div>\n <div id=\"routeResult\" class=\"section\"></div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"banner\">\n <svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>\n <div><div class=\"banner-title\">高级 CLI(全量)</div><div class=\"banner-body\">全部命令 + 人话说明 + 可复制示例;网页只展示与复制,不代替终端执行安装、更新与删除。</div></div>\n </div>\n <div class=\"section panel\">\n <div class=\"shead\"><h2>命令速查</h2><span class=\"faint\" id=\"cliMeta\">加载中…</span></div>\n <div id=\"cliQuick\"></div>\n </div>\n <div class=\"section panel\">\n <details id=\"cliFullWrap\">\n <summary>完整命令与参数(点击展开)</summary>\n <div id=\"cliFull\"></div>\n </details>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"mask\"><div class=\"modal\" id=\"modal\"></div></div>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n var TOKEN = (document.querySelector('meta[name=\"yotta-view-token\"]') || {}).content || '';\n var state = { view: 'overview', overview: null, hosts: null, adopt: null, selected: {}, records: null, tab: 'snapshots', hostFilter: '', adoptFilter: '', ovSkillFilter: '' };\n\n function $(sel) { return document.querySelector(sel); }\n function esc(value) {\n return String(value === null || value === undefined ? '' : value)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n function fmtTime(value) {\n var ms = typeof value === 'number' ? value : Date.parse(value);\n if (!ms || isNaN(ms)) return value ? String(value) : '-';\n var d = new Date(ms);\n function p(n) { return n < 10 ? '0' + n : '' + n; }\n return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());\n }\n function toast(text) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.textContent = text;\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function copyText(text) {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { toast('已复制'); }, function () { fallbackCopy(text); });\n } else fallbackCopy(text);\n }\n function fallbackCopy(text) {\n var ta = document.createElement('textarea');\n ta.value = text; document.body.appendChild(ta); ta.select();\n try { document.execCommand('copy'); toast('已复制'); } catch (e) { toast('复制失败,请手动选择'); }\n ta.remove();\n }\n async function api(path, opts) {\n opts = opts || {};\n var init = { method: opts.method || 'GET', headers: {} };\n if (opts.body !== undefined) {\n init.method = 'POST';\n init.headers['Content-Type'] = 'application/json';\n init.headers['X-Yotta-View-Token'] = TOKEN;\n init.body = JSON.stringify(opts.body);\n }\n var res, data = null;\n try { res = await fetch(path, init); } catch (e) { return { error: '无法连接本地服务:' + e.message }; }\n try { data = await res.json(); } catch (e) { data = null; }\n if (!res.ok) return data && data.error ? data : { error: '请求失败(HTTP ' + res.status + ')' };\n if (!data) return { error: '响应不是合法 JSON' };\n return data;\n }\n\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n var html = '<h3>' + esc(title) + '</h3><div id=\"modalBody\">' + bodyHtml + '</div>' +\n '<div class=\"modal-actions\"><button class=\"btn\" data-modal=\"cancel\">' + esc(opts.cancelText || '取消') + '</button>' +\n (opts.okText === null ? '' : '<button class=\"btn ' + (opts.danger ? 'danger' : 'primary') + '\" data-modal=\"ok\">' + esc(opts.okText || '确认') + '</button>') +\n '</div>';\n $('#modal').innerHTML = html;\n $('#mask').classList.add('open');\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n $('#modal').querySelector('[data-modal=\"cancel\"]').onclick = closeModal;\n if (ok && opts.onOk) {\n ok.onclick = async function () {\n ok.disabled = true;\n try { await opts.onOk(); } finally { ok.disabled = false; }\n };\n }\n }\n function closeModal() { $('#mask').classList.remove('open'); $('#modal').innerHTML = ''; }\n function confirmModal(title, bodyHtml, okText, danger, onOk) {\n openModal(title, bodyHtml, { okText: okText, danger: danger, onOk: onOk });\n }\n $('#mask').addEventListener('click', function (e) { if (e.target === $('#mask')) closeModal(); });\n document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });\n\n var VIEWS = {\n overview: { title: '概览', sub: 'Hub 里有什么、健康吗、最近做了什么', load: loadOverview },\n hosts: { title: '宿主矩阵', sub: '装了哪些宿主、技能目录在哪、Hub 技能链到了谁', load: loadHosts },\n adopt: { title: '收编向导', sub: '有哪些可收编、哪些是重复副本、收编后会发生什么', load: loadAdopt },\n links: { title: '链接与体检', sub: '断链 / 漂移 / 目标缺失在哪、怎么修', load: loadLinks },\n records: { title: '记录与回滚', sub: '安装、收编、链接都做了什么,怎么退回', load: loadRecords },\n route: { title: '路由与编排', sub: '一句话需求该用哪些技能、缺什么(只读)', load: null },\n cli: { title: '高级 CLI', sub: '全量命令速查与复制(与 CLI --help 同源)', load: null }\n };\n var loadSeq = 0;\n function showLoad(text) {\n loadSeq += 1;\n $('#loadbarText').textContent = text;\n $('#loadbar').hidden = false;\n $('#refreshBtn').disabled = true;\n return loadSeq;\n }\n function hideLoad(seq) {\n if (seq !== undefined && seq !== loadSeq) return;\n $('#loadbar').hidden = true;\n $('#refreshBtn').disabled = false;\n }\n async function runLoad(name) {\n var v = VIEWS[name];\n if (!v) return;\n if (!v.load) { loadSeq += 1; hideLoad(); return; }\n var seq = showLoad('正在加载' + v.title + '…');\n try { await v.load(); } finally { hideLoad(seq); }\n }\n function switchView(name) {\n if (!VIEWS[name]) return;\n state.view = name;\n Array.prototype.forEach.call(document.querySelectorAll('#nav button'), function (b) { b.classList.toggle('active', b.dataset.view === name); });\n Array.prototype.forEach.call(document.querySelectorAll('.view'), function (v) { v.classList.toggle('active', v.id === 'view-' + name); });\n $('#pageTitle').textContent = VIEWS[name].title;\n $('#pageSub').textContent = VIEWS[name].sub;\n runLoad(name);\n }\n $('#nav').addEventListener('click', function (e) {\n var b = e.target.closest('button[data-view]');\n if (b) switchView(b.dataset.view);\n });\n document.addEventListener('click', function (e) {\n var g = e.target.closest('[data-goto]');\n if (g) switchView(g.dataset.goto);\n });\n $('#refreshBtn').onclick = function () { var v = VIEWS[state.view]; if (v && v.load) runLoad(state.view); else toast('已刷新'); };\n $('#themeBtn').onclick = function () {\n document.body.classList.toggle('light');\n try { localStorage.setItem('yotta-hub-theme', document.body.classList.contains('light') ? 'light' : 'dark'); } catch (e) {}\n };\n try { if (localStorage.getItem('yotta-hub-theme') === 'light') document.body.classList.add('light'); } catch (e) {}\n\n function statCard(label, value, delta, tone) {\n return '<div class=\"panel\"><div class=\"label\">' + esc(label) + '</div><div class=\"value\">' + esc(value) + '</div><div class=\"delta ' + (tone ? 'badge ' + tone : '') + '\">' + esc(delta) + '</div></div>';\n }\n function doctorBanner(doctor) {\n if (!doctor) return '';\n if (doctor.ok) {\n return '<div class=\"banner ok\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><div><div class=\"banner-title\">体检通过</div><div class=\"banner-body\">警告 ' + doctor.summary.warnings + ' / 信息 ' + doctor.summary.info + ';结构性错误 0。</div></div></div>';\n }\n var bad = (doctor.checks || []).filter(function (c) { return !c.ok; }).slice(0, 6).map(function (c) {\n return '<div class=\"result\"><span class=\"badge ' + (c.severity === 'error' ? 'danger' : 'warn') + '\">' + esc(c.severity === 'error' ? '错误' : '警告') + '</span><span>' + esc(c.message) + '</span>' + (c.hint ? '<span class=\"faint\">修复:' + esc(c.hint) + '</span>' : '') + '</div>';\n }).join('');\n return '<div class=\"banner danger\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/></svg><div style=\"flex:1\"><div class=\"banner-title\">体检未通过(错误 ' + doctor.summary.errors + ' / 警告 ' + doctor.summary.warnings + ')</div><div class=\"result-list\">' + bad + '</div></div></div>';\n }\n\n async function loadOverview() {\n var d = await api('/api/overview');\n if (d.error) { toast(d.error); return; }\n state.overview = d;\n $('#ovCards').innerHTML =\n statCard('Hub 技能', d.status.summary.skills, '外部来源 ' + d.status.summary.external, '') +\n statCard('已链接', d.status.summary.links, '宿主目录投影', 'ok') +\n statCard('异常链接', d.status.summary.brokenLinks, d.status.summary.brokenLinks ? '需要处理' : '全部正常', d.status.summary.brokenLinks ? 'danger' : 'ok') +\n statCard('Doctor', d.doctor.ok ? '通过' : '未通过', '错误 ' + d.doctor.summary.errors + ' / 警告 ' + d.doctor.summary.warnings, d.doctor.ok ? 'ok' : 'danger');\n $('#ovDoctor').innerHTML = doctorBanner(d.doctor);\n renderOverviewSkills();\n var audit = (d.audit && d.audit.recent) || [];\n $('#ovAudit').innerHTML = audit.length ? audit.slice().reverse().map(function (a) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(a.event || '动作') + ' · ' + esc(a.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(a.at)) + (a.verdict ? ' · ' + esc(a.verdict) : '') + '</div></div></div>';\n }).join('') : '<div class=\"empty\">还没有动作记录。</div>';\n $('#ovHubDir').textContent = d.hubDir;\n $('#copyHubDir').onclick = function () { copyText(d.hubDir); };\n }\n\n function renderOverviewSkills() {\n var d = state.overview;\n if (!d) return;\n var skills = (d.status && d.status.skills) || [];\n var filter = state.ovSkillFilter.trim().toLowerCase();\n var list = filter\n ? skills.filter(function (s) { return String(s.slug || '').toLowerCase().indexOf(filter) >= 0; })\n : skills.slice(0, 8);\n $('#ovSkills').innerHTML = list.length ? list.map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(s.slug) + '</div><div class=\"row-meta\">v' + esc(s.version || '-') + ' · ' + esc(s.origin === 'external' ? '外部来源' : '元阁家族') + ' · 链接 ' + (s.links || []).length + '</div></div>' +\n '<span class=\"badge ' + (s.status === 'present' ? 'ok' : 'danger') + '\">' + esc(s.status === 'present' ? '已入库' : '缺失') + '</span>' +\n '<div class=\"row-actions\"><button class=\"btn small danger\" data-remove-skill=\"' + esc(s.slug) + '\">删除</button></div></div>';\n }).join('') : '<div class=\"empty\">' + (filter ? '没有匹配的技能。' : 'Hub 还是空的。先在「收编向导」把已装技能收编进来。') + '</div>';\n }\n $('#ovSkillFilter').addEventListener('input', function () {\n state.ovSkillFilter = $('#ovSkillFilter').value || '';\n renderOverviewSkills();\n });\n $('#ovSkills').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-remove-skill]');\n if (!b) return;\n var slug = b.dataset.removeSkill;\n var plan = await api('/api/skills/remove-plan?slug=' + encodeURIComponent(slug));\n if (!plan || plan.error) { toast((plan && plan.error) || '影响预览失败'); return; }\n var targets = plan.targets || [];\n var willUnlink = targets.filter(function (t) { return t.action === 'unlink'; });\n var kept = targets.filter(function (t) { return t.action === 'keep'; });\n var rows = targets.map(function (t) {\n var tone = t.action === 'unlink' ? 'warn' : t.action === 'keep' ? 'info' : '';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(t.action === 'unlink' ? '将清理' : t.action === 'keep' ? '保留' : '无动作') + '</span><span class=\"mono\">' + esc(t.dir) + '</span><span class=\"faint\">' + esc(t.note || '') + '</span></div>';\n }).join('');\n var body = '<p class=\"label\">将清理 ' + willUnlink.length + ' 条宿主链接(含死链)' +\n (plan.hubPresent ? ',并把 Hub 目录移入回收站(保留 7 天)' : ';Hub 目录已缺失,仅清理残留') +\n ';保留 ' + kept.length + ' 项宿主真副本 / 外部链接。</p>' +\n '<div class=\"result-list\">' + (rows || '<div class=\"faint\">没有需要处理的宿主目录。</div>') + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入完整技能名确认:' + esc(slug) + '</label><input id=\"removeConfirmSlug\" placeholder=\"' + esc(slug) + '\"></div>';\n confirmModal('删除 Hub 技能 ' + slug + '?', body, '删除', true, async function () {\n var typed = ($('#removeConfirmSlug') && $('#removeConfirmSlug').value || '').trim();\n if (typed !== slug) { toast('技能名不匹配'); return; }\n var r = await api('/api/skills/remove', { body: { slug: slug, confirm: 'remove', confirmSlug: typed } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.verdict === 'cleanup-only' ? '已清理残留(Hub 目录原本缺失)' : '已删除 ' + slug + '(目录已入回收站)');\n loadOverview();\n });\n });\n\n async function loadHosts() {\n var d = await api('/api/hosts');\n if (d.error) { toast(d.error); return; }\n state.hosts = d;\n $('#hostSummary').textContent = '目录 ' + d.summary.existing + ' / ' + d.summary.hosts + '(已核实 ' + d.summary.verified + ' · 自动发现 ' + d.summary.discovered + ' · 桥接 ' + d.summary.bridge + ')· 已装标记 ' + d.summary.installedMarks + ' · 链接 ' + d.summary.links;\n renderHosts();\n var marks = d.installed || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + (m.hasSkillsDir ? ' · 技能目录:' + esc(m.skillDir || '') : ' · 未发现技能目录') + '</div></div><span class=\"badge ' + (m.hasSkillsDir ? 'ok' : 'info') + '\">' + esc(m.hasSkillsDir ? '可链接' : '仅标记') + '</span></div>';\n }).join('') : '<div class=\"empty\">没有检测到额外宿主标记。</div>';\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var rows = (d.hosts || []).filter(function (h) {\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n $('#hostRows').innerHTML = rows.map(function (h) {\n var status = !h.exists ? '<span class=\"badge\">未创建</span>' : (h.brokenLinks ? '<span class=\"badge danger\">有异常</span>' : '<span class=\"badge ok\">可用</span>');\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n return '<tr><td><div class=\"cell-main\">' + esc(h.label) + '</div><div class=\"cell-sub\">' + esc(h.agentId || (h.known ? '已知宿主' : h.detection || '自动发现')) + '</div></td>' +\n '<td><div class=\"mono\">' + esc(h.dir) + '</div><button class=\"btn small\" data-copy=\"' + esc(h.dir) + '\">复制</button></td>' +\n '<td>' + scopeBadge + '</td>' +\n '<td>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + status + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy]');\n if (b) copyText(b.dataset.copy);\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链接</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n var action = h.bridgeOnly ? '' : '<button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button>';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + ' ' + scopeBadge + '</div><div class=\"row-meta mono\">' + esc(h.dir) + '</div><div class=\"row-meta\">目录技能 ' + esc(h.skillCount || 0) + ' · 指向 Hub ' + esc(h.hubLinks || 0) + (h.brokenLinks ? ' · 异常 ' + h.brokenLinks : '') + '</div></div><div class=\"row-actions\">' + action + '</div></div>';\n }).join('') : '<div class=\"empty\">尚未发现可用的宿主技能目录。</div>';\n var rows = links.links || [];\n $('#linkEmpty').hidden = rows.length > 0;\n $('#linkRows').innerHTML = rows.map(function (l) {\n var tone = l.status === 'ok' ? 'ok' : l.status === 'broken' ? 'danger' : 'warn';\n return '<tr><td><div class=\"cell-main\">' + esc(l.slug) + '</div><div class=\"cell-sub\">' + esc(l.label || l.agent || '-') + '</div></td>' +\n '<td class=\"mono\">' + esc(l.dir) + '</td><td class=\"mono\">' + esc(l.target) + '</td>' +\n '<td><span class=\"badge ' + tone + '\">' + esc(l.status) + '</span></td>' +\n '<td><button class=\"btn small danger\" data-unlink=\"' + esc(l.target) + '\" data-dir=\"' + esc(l.dir) + '\" data-slug=\"' + esc(l.slug) + '\">解除</button></td></tr>';\n }).join('');\n }\n $('#linkTargets').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-link-plan]');\n if (!b) return;\n var dir = b.dataset.linkPlan;\n var plan = await api('/api/links/plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var would = plan.results.filter(function (r) { return r.status === 'would-link'; });\n var converge = plan.results.filter(function (r) { return r.status === 'would-converge'; });\n var skipped = plan.results.filter(function (r) { return r.status === 'skipped'; });\n var conflicts = plan.results.filter(function (r) { return r.status === 'conflict'; });\n var convergeMoves = converge.reduce(function (n, r) { return n + ((r.moves && r.moves.length) || 0); }, 0);\n var linkable = would.length + converge.length;\n var body = '<p class=\"label\">目标:' + esc(plan.label || '') + ' · <span class=\"mono\">' + esc(dir) + '</span></p>' +\n '<div class=\"result\"><span class=\"badge ok\">将建立</span><b>' + linkable + '</b><span>个链接</span></div>' +\n (converge.length ? '<div class=\"result\"><span class=\"badge warn\">将收敛</span><b>' + convergeMoves + '</b><span>份旧副本 → 回收站(保留 7 天,可恢复):' + esc(converge.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (skipped.length ? '<div class=\"result\"><span class=\"badge warn\">跳过</span><b>' + skipped.length + '</b><span>项(Hub 版本旧于宿主 / 版本无法解析):' + esc(skipped.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (conflicts.length ? '<div class=\"result\"><span class=\"badge warn\">跳过冲突</span><b>' + conflicts.length + '</b><span>个同名目录 / 链接(默认不覆盖)</span></div>' : '') +\n (linkable ? '<details><summary>查看明细</summary><div class=\"variants\">' + would.concat(converge).map(function (r) { return '<div class=\"variant\"><span class=\"mono\">' + esc(r.slug) + '</span></div>'; }).join('') + '</div></details>' : '<p class=\"faint\">没有可建立的链接。</p>');\n openModal('链接预览', body, {\n okText: linkable ? '执行链接' : null,\n onOk: async function () {\n var r = await api('/api/links/apply', { body: { targetDir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var done = (r.results || []).filter(function (x) { return x.status === 'linked'; }).length;\n var moved = (r.results || []).reduce(function (n, x) { return n + ((x.moved && x.moved.length) || 0); }, 0);\n toast('已建立 ' + done + ' 个链接' + (moved ? ';收敛 ' + moved + ' 份旧副本(回收站 7 天)' : ''));\n loadLinks();\n },\n });\n });\n $('#linkRows').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-unlink]');\n if (!b) return;\n var target = b.dataset.unlink;\n var dir = b.dataset.dir;\n var slug = b.dataset.slug;\n confirmModal('解除链接 ' + slug + '?', '<p class=\"label\">只删除宿主目录中的链接,Hub 真源保留。目标不是指向 Hub 的链接时会 fail-closed 拒绝。</p><div class=\"mono\">' + esc(target) + '</div>', '解除链接', true, async function () {\n var r = await api('/api/links/remove', { body: { targetDir: dir, slugs: [slug], confirm: 'unlink' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已解除 ' + slug);\n loadLinks();\n });\n });\n\n async function loadRecords() {\n var d = await api('/api/records?limit=200');\n if (d.error) { toast(d.error); return; }\n state.records = d;\n var valid = (d.snapshots || []).filter(function (s) { return s.valid; }).length;\n $('#recordsInfo').textContent = '快照 ' + valid + ' / ' + (d.snapshots || []).length + ' · 证据 ' + (d.evidence.entries || []).length + ' · 审计 ' + (d.audit.entries || []).length;\n renderSnapshots();\n var ev = (d.evidence.entries || []).slice().reverse();\n $('#evidenceEmpty').hidden = ev.length > 0;\n $('#evidenceRows').innerHTML = ev.map(function (x) {\n var tone = x.decision === 'ok' ? 'ok' : x.decision === 'fail' ? 'danger' : 'info';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'event') + ' · ' + esc(x.skill || x.slug || '-') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.ts || x.at)) + (x.version ? ' · v' + esc(x.version) : '') + (x.via ? ' · via ' + esc(x.via) : '') + '</div></div><span class=\"badge ' + tone + '\">' + esc(x.decision || '记录') + '</span></div>';\n }).join('');\n var aud = (d.audit.entries || []).slice().reverse();\n $('#auditEmpty').hidden = aud.length > 0;\n $('#auditRows').innerHTML = aud.map(function (x) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'audit') + ' · ' + esc(x.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.at)) + (x.verdict ? ' · ' + esc(x.verdict) : '') + (x.sourceHost ? ' · ' + esc(x.sourceHost) : '') + '</div></div></div>';\n }).join('');\n }\n function renderSnapshots() {\n var d = state.records; if (!d) return;\n var rows = d.snapshots || [];\n $('#snapshotEmpty').hidden = rows.length > 0;\n $('#snapshotRows').innerHTML = rows.map(function (s) {\n var canRollback = s.valid && s.source;\n return '<tr><td><div class=\"cell-main\">' + esc(s.slug) + '</div><div class=\"cell-sub\">' + esc(s.name) + '</div></td>' +\n '<td>v' + esc(s.version || '-') + '</td><td>' + esc(fmtTime(s.createdAt || s.mtime_ms)) + '</td><td class=\"mono\">' + esc(s.source || '-') + '</td>' +\n '<td>' + (s.valid ? '<span class=\"badge ok\">可用</span>' : '<span class=\"badge danger\">不可用</span>') + (s.legacy ? ' <span class=\"badge warn\">旧格式</span>' : '') + (s.reason ? '<div class=\"cell-sub\">' + esc(s.reason) + '</div>' : '') + '</td>' +\n '<td>' + (canRollback ? '<button class=\"btn small danger\" data-rollback=\"' + esc(s.path) + '\" data-slug=\"' + esc(s.slug) + '\" data-version=\"' + esc(s.version || '') + '\" data-source=\"' + esc(s.source || '') + '\">回滚</button>' : '<span class=\"faint\">CLI 处理</span>') + '</td></tr>';\n }).join('');\n }\n document.querySelectorAll('#view-records [data-tab]').forEach(function (b) {\n b.onclick = function () {\n state.tab = b.dataset.tab;\n Array.prototype.forEach.call(document.querySelectorAll('#view-records [data-tab]'), function (x) { x.classList.toggle('primary', x === b); });\n $('#recSnapshots').hidden = state.tab !== 'snapshots';\n $('#recEvidence').hidden = state.tab !== 'evidence';\n $('#recAudit').hidden = state.tab !== 'audit';\n };\n });\n document.querySelector('#view-records [data-tab=\"snapshots\"]').classList.add('primary');\n $('#snapshotRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-rollback]');\n if (!b) return;\n var slug = b.dataset.slug, snap = b.dataset.rollback, source = b.dataset.source;\n confirmModal('回滚 ' + slug + ' 到 v' + b.dataset.version + '?', '<p class=\"label\">将用快照覆盖安装来源目录;恢复后自动体检并写入证据。</p><div class=\"mono\">' + esc(source) + '</div>', '执行回滚', true, async function () {\n var r = await api('/api/rollback/apply', { body: { slug: slug, snapshot: snap, confirm: 'rollback' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.ok ? '回滚完成(doctor 通过)' : '回滚完成,但体检未通过');\n loadRecords();\n loadOverview();\n });\n });\n\n $('#routeBtn').onclick = runRoute;\n $('#routeInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') runRoute(); });\n async function runRoute() {\n var request = $('#routeInput').value.trim();\n if (!request) { toast('先输入一句需求'); return; }\n var seq = showLoad('正在生成路由与编排…');\n $('#routeBtn').disabled = true;\n var d = await api('/api/route?request=' + encodeURIComponent(request));\n hideLoad(seq);\n $('#routeBtn').disabled = false;\n if (d.error) { toast(d.error); return; }\n var skills = (d.skills || []).map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + s.order + '. ' + esc(s.slug) + ' <span class=\"badge ' + (s.installed ? 'ok' : 'warn') + '\">' + esc(s.installed ? '已装' + (s.version ? ' v' + s.version : '') : '缺失') + '</span></div><div class=\"row-meta\">' + esc(s.role || '') + '</div></div></div>';\n }).join('');\n var missing = (d.missing_skills || []).length\n ? '<div class=\"section panel\"><h3>缺失技能</h3><div class=\"toolbar\"><span class=\"mono\">' + esc((d.missing_skills || []).map(function (s) { return s.slug; }).join(', ')) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(d.install_command || '') + '\">复制安装命令</button></div><div class=\"note\">安装与信任判断由你决定;元信装前扫描默认启用。</div></div>'\n : '';\n var others = (d.other_skill_candidates || []).length\n ? '<div class=\"section panel\"><h3>其他已装技能候选</h3>' + d.other_skill_candidates.map(function (c) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge info\">v' + esc(c.version) + '</span></div><div class=\"row-meta\">' + esc((c.matched_terms || []).join('、')) + ' · ' + esc(c.description || '') + '</div></div></div>';\n }).join('') + '<div class=\"note\">只读 frontmatter 机械匹配;使用前请自行确认。</div></div>'\n : '';\n $('#routeResult').innerHTML = '<div class=\"panel\"><div class=\"shead\"><h2>' + esc(d.playbook.name) + '</h2><span class=\"badge\">置信度 ' + esc(d.confidence) + '</span></div>' +\n '<div class=\"label\">' + esc(d.playbook.intent) + '</div><div class=\"list\">' + skills + '</div>' +\n '<div class=\"note\">动态路由:' + esc((d.dynamic && d.dynamic.status) || '未启用') + ' · 匹配词:' + esc((d.matched_keywords || []).join('、') || '无') + '</div></div>' + missing + others;\n }\n $('#routeResult').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n /* 高级 CLI:/api/help 单一真源(与 CLI --help 同源) */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function renderCliHelp(d) {\n if (!d || d.error) { $('#cliMeta').textContent = '帮助加载失败'; return; }\n var total = 0;\n (d.groups || []).forEach(function (g) { total += (g.commands || []).length; });\n $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + (d.options || []).length;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.items || []).map(function (item) {\n var meta = findHelpEntry(d.groups, item.cmd) || {};\n return '<div class=\"cmd\"><code>' + esc(item.example) + '</code><span class=\"why\">' + esc(meta.what || '') +\n (item.note ? ' · ' + esc(item.note) : '') + '</span>' +\n (item.danger ? '<span class=\"badge danger\">危险</span>' : '') +\n '<button class=\"btn small\" data-copy-cmd=\"' + esc(item.example) + '\">复制</button></div>';\n }).join('') + '</div>';\n }).join('');\n var full = (d.groups || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.commands || []).map(function (c) {\n var opts = (c.options || []).length\n ? '<div class=\"note\">常用选项: ' + c.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'\n : '';\n var subs = (c.subcommands || []).map(function (s) {\n return '<div class=\"cmd\"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class=\"why\">' + esc(s.what || '') + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc('yotta-skills ' + s.usage) + '\">复制</button></div>';\n }).join('');\n return '<details class=\"cmd-detail\"><summary><code>' + esc('yotta-skills ' + c.usage) + '</code> <span class=\"why\">' + esc(c.what || '') + '</span></summary>' +\n (c.when ? '<div class=\"note\">' + esc(c.when) + '</div>' : '') + opts + subs + '</details>';\n }).join('') + '</div>';\n }).join('');\n full += '<div class=\"cmd-group\"><h3>完整选项</h3>' + (d.options || []).map(function (o) {\n return '<div class=\"cmd\"><code>' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</code><span class=\"why\">' + esc(o.what || '') +\n (o.when ? ' · ' + esc(o.when) : '') + (o.caution ? ' · ⚠ ' + esc(o.caution) : '') + '</span></div>';\n }).join('') + '</div>';\n $('#cliFull').innerHTML = full;\n }\n api('/api/help').then(renderCliHelp);\n document.addEventListener('click', function (e) {\n var b = e.target.closest('#view-cli [data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n (async function boot() {\n var s = await api('/api/status');\n if (s && !s.error) $('#verLine').textContent = 'v' + s.version + ' · ' + (s.standard || '');\n else $('#verLine').textContent = '未连接本地服务';\n runLoad('overview');\n })();\n})();\n</script>\n</body>\n</html>\n";
66
+ const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .loadbar{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px;padding:10px 13px;border:1px dashed var(--line2);border-radius:8px;margin:0 0 14px;background:var(--panel)}\n .spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--acc);animation:spin .8s linear infinite;flex:none}\n @keyframes spin{to{transform:rotate(360deg)}}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .chips{display:flex;gap:6px;flex-wrap:wrap}\n .chip{cursor:pointer;background:transparent}\n .chip.active{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n .cmd-detail{border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:8px;padding:0 11px;min-width:0}\n .cmd-detail summary{cursor:pointer;padding:9px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}\n .cmd-detail .note{padding:0 0 8px}\n .cmd-detail .cmd{margin:0 0 8px}\n\n @media (max-width:980px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:13px;gap:11px}\n .nav{flex-direction:row;overflow-x:auto;width:100%;scrollbar-width:none;-webkit-overflow-scrolling:touch}\n .nav::-webkit-scrollbar{display:none}\n .nav button{white-space:nowrap;padding:7px 9px}\n .side-foot{margin-top:0;flex-direction:row;align-items:center}\n .privacy{display:none}\n .ghost-btn{flex:1;white-space:nowrap}\n .verline{white-space:nowrap;text-align:right}\n .main{padding:16px 14px 56px}\n .g4,.g2{grid-template-columns:minmax(0,1fr)}\n .search{min-width:100%}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}\n }\n</style>\n</head>\n<body>\n<div class=\"shell\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3 4 7v4c0 4.4 3.4 8.4 8 10 4.6-1.6 8-5.6 8-10V7z\"/><path d=\"M9 12l2 2 4-4\"/></svg></div>\n <div><div class=\"brand-name\">元阁 · 技能枢纽</div><div class=\"brand-sub\">Hub 本地面板</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"hosts\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"13\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 7.5h.01M7 16.5h.01\"/></svg>宿主矩阵</button>\n <button data-view=\"adopt\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3v12\"/><path d=\"m7 10 5 5 5-5\"/><path d=\"M4 21h16\"/></svg>收编向导</button>\n <button data-view=\"links\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1L11 4.9\"/><path d=\"M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1-1\"/></svg>链接与体检</button>\n <button data-view=\"records\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/><path d=\"M12 8v5l3 2\"/></svg>记录与回滚</button>\n <button data-view=\"route\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"18\" cy=\"18\" r=\"2.5\"/><path d=\"M8.5 6H15a3 3 0 0 1 0 6h-6a3 3 0 0 0 0 6h6.5\"/></svg>路由与编排</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>高级 CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>本机运行 · 零远程<br>写操作需页面令牌与确认</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n <div class=\"verline\" id=\"verLine\"></div>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">Hub 里有什么、健康吗、最近做了什么</div></div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"refreshBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>刷新</button>\n <div class=\"chip\"><span class=\"dot\"></span>本机 · 127.0.0.1</div>\n </div>\n <div class=\"loadbar\" id=\"loadbar\" hidden><span class=\"spinner\"></span><span id=\"loadbarText\">正在加载…</span></div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\" id=\"ovCards\"></div>\n <div class=\"section\" id=\"ovDoctor\"></div>\n <div class=\"section grid g2\">\n <div class=\"panel\"><h3>Hub 内容</h3><div class=\"faint\">技能真源在 Hub 目录,宿主目录通过链接分发。</div>\n <label class=\"search section\" style=\"min-width:100%\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"ovSkillFilter\" placeholder=\"过滤技能(输入后显示全部匹配)\"></label>\n <div class=\"list\" id=\"ovSkills\"></div>\n </div>\n <div class=\"panel\"><h3>最近动作</h3><div class=\"faint\">收编 / 链接 / 解除都写入 Hub 审计。</div><div class=\"list\" id=\"ovAudit\"></div></div>\n </div>\n <div class=\"section panel\">\n <h3>Hub 位置</h3>\n <div class=\"mono\" id=\"ovHubDir\">-</div>\n <div class=\"toolbar section\">\n <button class=\"btn small\" data-goto=\"adopt\">去收编</button>\n <button class=\"btn small\" data-goto=\"links\">去链接与体检</button>\n <button class=\"btn small\" data-goto=\"records\">查看记录</button>\n <button class=\"btn small\" id=\"copyHubDir\">复制路径</button>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-hosts\">\n <div class=\"toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"hostSearch\" placeholder=\"过滤宿主 / 目录\"></label>\n <div class=\"chips\" id=\"hostStateChips\"></div>\n <button class=\"btn small\" id=\"hostAddBtn\">添加自定义目录</button>\n </div>\n <div class=\"section panel\">\n <div class=\"faint\" id=\"hostSummary\"></div>\n <table><thead><tr><th>宿主</th><th>技能目录</th><th>范围</th><th>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th><th></th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n <div class=\"section panel\" id=\"hostExcludedWrap\" hidden>\n <h3>不接管名单</h3>\n <div class=\"faint\">这些宿主 / 目录在发现、显示、链接三层全部跳过(--include-discovered 也不纳入)。</div>\n <div class=\"list\" id=\"hostExcluded\"></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);默认范围 = 已核实宿主,自动发现目录需在 CLI 用 --include-discovered 显式纳入,锁 / 数据桥接目录永不链接;元技能链接时收敛旧副本(移入回收站,保留 7 天可恢复)。</div>\n <div class=\"list\" id=\"linkTargets\"></div>\n </div>\n <div class=\"section panel\">\n <h3>当前链接</h3>\n <table><thead><tr><th>技能</th><th>宿主</th><th>目标</th><th>状态</th><th></th></tr></thead><tbody id=\"linkRows\"></tbody></table>\n <div class=\"empty\" id=\"linkEmpty\" hidden>还没有任何链接。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-records\">\n <div class=\"toolbar\">\n <button class=\"btn small\" data-tab=\"snapshots\">回滚快照</button>\n <button class=\"btn small\" data-tab=\"evidence\">安装 / 回滚证据</button>\n <button class=\"btn small\" data-tab=\"audit\">Hub 审计</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"recordsInfo\"></span>\n </div>\n <div class=\"section panel\" id=\"recSnapshots\">\n <table><thead><tr><th>技能</th><th>版本</th><th>时间</th><th>来源目录</th><th>状态</th><th></th></tr></thead><tbody id=\"snapshotRows\"></tbody></table>\n <div class=\"empty\" id=\"snapshotEmpty\" hidden>没有安装快照。快照由 install / update 写入。</div>\n </div>\n <div class=\"section panel\" id=\"recEvidence\" hidden>\n <div class=\"list\" id=\"evidenceRows\"></div>\n <div class=\"empty\" id=\"evidenceEmpty\" hidden>没有证据记录。</div>\n </div>\n <div class=\"section panel\" id=\"recAudit\" hidden>\n <div class=\"list\" id=\"auditRows\"></div>\n <div class=\"empty\" id=\"auditEmpty\" hidden>没有 Hub 审计记录。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-route\">\n <div class=\"panel\">\n <h3>一句话需求 → 技能组合</h3>\n <div class=\"faint\">只读调用静态 playbook(7 组)+ 动态路由块;安装建议只给命令,不在网页执行。</div>\n <div class=\"toolbar section\">\n <label class=\"search\" style=\"flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"routeInput\" placeholder=\"例如:帮我做发布前质量检查\"></label>\n <button class=\"btn primary\" id=\"routeBtn\">给出组合</button>\n </div>\n </div>\n <div id=\"routeResult\" class=\"section\"></div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"banner\">\n <svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>\n <div><div class=\"banner-title\">高级 CLI(全量)</div><div class=\"banner-body\">全部命令 + 人话说明 + 可复制示例;网页只展示与复制,不代替终端执行安装、更新与删除。</div></div>\n </div>\n <div class=\"section panel\">\n <div class=\"shead\"><h2>命令速查</h2><span class=\"faint\" id=\"cliMeta\">加载中…</span></div>\n <div id=\"cliQuick\"></div>\n </div>\n <div class=\"section panel\">\n <details id=\"cliFullWrap\">\n <summary>完整命令与参数(点击展开)</summary>\n <div id=\"cliFull\"></div>\n </details>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"mask\"><div class=\"modal\" id=\"modal\"></div></div>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n var TOKEN = (document.querySelector('meta[name=\"yotta-view-token\"]') || {}).content || '';\n var state = { view: 'overview', overview: null, hosts: null, adopt: null, selected: {}, records: null, tab: 'snapshots', hostFilter: '', hostStateFilter: '', adoptFilter: '', ovSkillFilter: '' };\n\n function $(sel) { return document.querySelector(sel); }\n function esc(value) {\n return String(value === null || value === undefined ? '' : value)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n function fmtTime(value) {\n var ms = typeof value === 'number' ? value : Date.parse(value);\n if (!ms || isNaN(ms)) return value ? String(value) : '-';\n var d = new Date(ms);\n function p(n) { return n < 10 ? '0' + n : '' + n; }\n return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());\n }\n function toast(text) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.textContent = text;\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function copyText(text) {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { toast('已复制'); }, function () { fallbackCopy(text); });\n } else fallbackCopy(text);\n }\n function fallbackCopy(text) {\n var ta = document.createElement('textarea');\n ta.value = text; document.body.appendChild(ta); ta.select();\n try { document.execCommand('copy'); toast('已复制'); } catch (e) { toast('复制失败,请手动选择'); }\n ta.remove();\n }\n async function api(path, opts) {\n opts = opts || {};\n var init = { method: opts.method || 'GET', headers: {} };\n if (opts.body !== undefined) {\n init.method = 'POST';\n init.headers['Content-Type'] = 'application/json';\n init.headers['X-Yotta-View-Token'] = TOKEN;\n init.body = JSON.stringify(opts.body);\n }\n var res, data = null;\n try { res = await fetch(path, init); } catch (e) { return { error: '无法连接本地服务:' + e.message }; }\n try { data = await res.json(); } catch (e) { data = null; }\n if (!res.ok) return data && data.error ? data : { error: '请求失败(HTTP ' + res.status + ')' };\n if (!data) return { error: '响应不是合法 JSON' };\n return data;\n }\n\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n var html = '<h3>' + esc(title) + '</h3><div id=\"modalBody\">' + bodyHtml + '</div>' +\n '<div class=\"modal-actions\"><button class=\"btn\" data-modal=\"cancel\">' + esc(opts.cancelText || '取消') + '</button>' +\n (opts.okText === null ? '' : '<button class=\"btn ' + (opts.danger ? 'danger' : 'primary') + '\" data-modal=\"ok\">' + esc(opts.okText || '确认') + '</button>') +\n '</div>';\n $('#modal').innerHTML = html;\n $('#mask').classList.add('open');\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n $('#modal').querySelector('[data-modal=\"cancel\"]').onclick = closeModal;\n if (ok && opts.onOk) {\n ok.onclick = async function () {\n ok.disabled = true;\n try { await opts.onOk(); } finally { ok.disabled = false; }\n };\n }\n }\n function closeModal() { $('#mask').classList.remove('open'); $('#modal').innerHTML = ''; }\n function confirmModal(title, bodyHtml, okText, danger, onOk) {\n openModal(title, bodyHtml, { okText: okText, danger: danger, onOk: onOk });\n }\n $('#mask').addEventListener('click', function (e) { if (e.target === $('#mask')) closeModal(); });\n document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });\n\n var VIEWS = {\n overview: { title: '概览', sub: 'Hub 里有什么、健康吗、最近做了什么', load: loadOverview },\n hosts: { title: '宿主矩阵', sub: '装了哪些宿主、技能目录在哪、Hub 技能链到了谁', load: loadHosts },\n adopt: { title: '收编向导', sub: '有哪些可收编、哪些是重复副本、收编后会发生什么', load: loadAdopt },\n links: { title: '链接与体检', sub: '断链 / 漂移 / 目标缺失在哪、怎么修', load: loadLinks },\n records: { title: '记录与回滚', sub: '安装、收编、链接都做了什么,怎么退回', load: loadRecords },\n route: { title: '路由与编排', sub: '一句话需求该用哪些技能、缺什么(只读)', load: null },\n cli: { title: '高级 CLI', sub: '全量命令速查与复制(与 CLI --help 同源)', load: null }\n };\n var loadSeq = 0;\n function showLoad(text) {\n loadSeq += 1;\n $('#loadbarText').textContent = text;\n $('#loadbar').hidden = false;\n $('#refreshBtn').disabled = true;\n return loadSeq;\n }\n function hideLoad(seq) {\n if (seq !== undefined && seq !== loadSeq) return;\n $('#loadbar').hidden = true;\n $('#refreshBtn').disabled = false;\n }\n async function runLoad(name) {\n var v = VIEWS[name];\n if (!v) return;\n if (!v.load) { loadSeq += 1; hideLoad(); return; }\n var seq = showLoad('正在加载' + v.title + '…');\n try { await v.load(); } finally { hideLoad(seq); }\n }\n function switchView(name) {\n if (!VIEWS[name]) return;\n state.view = name;\n Array.prototype.forEach.call(document.querySelectorAll('#nav button'), function (b) { b.classList.toggle('active', b.dataset.view === name); });\n Array.prototype.forEach.call(document.querySelectorAll('.view'), function (v) { v.classList.toggle('active', v.id === 'view-' + name); });\n $('#pageTitle').textContent = VIEWS[name].title;\n $('#pageSub').textContent = VIEWS[name].sub;\n runLoad(name);\n }\n $('#nav').addEventListener('click', function (e) {\n var b = e.target.closest('button[data-view]');\n if (b) switchView(b.dataset.view);\n });\n document.addEventListener('click', function (e) {\n var g = e.target.closest('[data-goto]');\n if (g) switchView(g.dataset.goto);\n });\n $('#refreshBtn').onclick = function () { var v = VIEWS[state.view]; if (v && v.load) runLoad(state.view); else toast('已刷新'); };\n $('#themeBtn').onclick = function () {\n document.body.classList.toggle('light');\n try { localStorage.setItem('yotta-hub-theme', document.body.classList.contains('light') ? 'light' : 'dark'); } catch (e) {}\n };\n try { if (localStorage.getItem('yotta-hub-theme') === 'light') document.body.classList.add('light'); } catch (e) {}\n\n function statCard(label, value, delta, tone) {\n return '<div class=\"panel\"><div class=\"label\">' + esc(label) + '</div><div class=\"value\">' + esc(value) + '</div><div class=\"delta ' + (tone ? 'badge ' + tone : '') + '\">' + esc(delta) + '</div></div>';\n }\n function doctorBanner(doctor) {\n if (!doctor) return '';\n if (doctor.ok) {\n return '<div class=\"banner ok\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><div><div class=\"banner-title\">体检通过</div><div class=\"banner-body\">警告 ' + doctor.summary.warnings + ' / 信息 ' + doctor.summary.info + ';结构性错误 0。</div></div></div>';\n }\n var bad = (doctor.checks || []).filter(function (c) { return !c.ok; }).slice(0, 6).map(function (c) {\n return '<div class=\"result\"><span class=\"badge ' + (c.severity === 'error' ? 'danger' : 'warn') + '\">' + esc(c.severity === 'error' ? '错误' : '警告') + '</span><span>' + esc(c.message) + '</span>' + (c.hint ? '<span class=\"faint\">修复:' + esc(c.hint) + '</span>' : '') + '</div>';\n }).join('');\n return '<div class=\"banner danger\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/></svg><div style=\"flex:1\"><div class=\"banner-title\">体检未通过(错误 ' + doctor.summary.errors + ' / 警告 ' + doctor.summary.warnings + ')</div><div class=\"result-list\">' + bad + '</div></div></div>';\n }\n\n async function loadOverview() {\n var d = await api('/api/overview');\n if (d.error) { toast(d.error); return; }\n state.overview = d;\n $('#ovCards').innerHTML =\n statCard('Hub 技能', d.status.summary.skills, '外部来源 ' + d.status.summary.external, '') +\n statCard('已链接', d.status.summary.links, '宿主目录投影', 'ok') +\n statCard('异常链接', d.status.summary.brokenLinks, d.status.summary.brokenLinks ? '需要处理' : '全部正常', d.status.summary.brokenLinks ? 'danger' : 'ok') +\n statCard('Doctor', d.doctor.ok ? '通过' : '未通过', '错误 ' + d.doctor.summary.errors + ' / 警告 ' + d.doctor.summary.warnings, d.doctor.ok ? 'ok' : 'danger');\n $('#ovDoctor').innerHTML = doctorBanner(d.doctor);\n renderOverviewSkills();\n var audit = (d.audit && d.audit.recent) || [];\n $('#ovAudit').innerHTML = audit.length ? audit.slice().reverse().map(function (a) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(a.event || '动作') + ' · ' + esc(a.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(a.at)) + (a.verdict ? ' · ' + esc(a.verdict) : '') + '</div></div></div>';\n }).join('') : '<div class=\"empty\">还没有动作记录。</div>';\n $('#ovHubDir').textContent = d.hubDir;\n $('#copyHubDir').onclick = function () { copyText(d.hubDir); };\n }\n\n function renderOverviewSkills() {\n var d = state.overview;\n if (!d) return;\n var skills = (d.status && d.status.skills) || [];\n var filter = state.ovSkillFilter.trim().toLowerCase();\n var list = filter\n ? skills.filter(function (s) { return String(s.slug || '').toLowerCase().indexOf(filter) >= 0; })\n : skills.slice(0, 8);\n $('#ovSkills').innerHTML = list.length ? list.map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(s.slug) + '</div><div class=\"row-meta\">v' + esc(s.version || '-') + ' · ' + esc(s.origin === 'external' ? '外部来源' : '元阁家族') + ' · 链接 ' + (s.links || []).length + '</div></div>' +\n '<span class=\"badge ' + (s.status === 'present' ? 'ok' : 'danger') + '\">' + esc(s.status === 'present' ? '已入库' : '缺失') + '</span>' +\n '<div class=\"row-actions\"><button class=\"btn small danger\" data-remove-skill=\"' + esc(s.slug) + '\">删除</button></div></div>';\n }).join('') : '<div class=\"empty\">' + (filter ? '没有匹配的技能。' : 'Hub 还是空的。先在「收编向导」把已装技能收编进来。') + '</div>';\n }\n $('#ovSkillFilter').addEventListener('input', function () {\n state.ovSkillFilter = $('#ovSkillFilter').value || '';\n renderOverviewSkills();\n });\n $('#ovSkills').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-remove-skill]');\n if (!b) return;\n var slug = b.dataset.removeSkill;\n var plan = await api('/api/skills/remove-plan?slug=' + encodeURIComponent(slug));\n if (!plan || plan.error) { toast((plan && plan.error) || '影响预览失败'); return; }\n var targets = plan.targets || [];\n var willUnlink = targets.filter(function (t) { return t.action === 'unlink'; });\n var kept = targets.filter(function (t) { return t.action === 'keep'; });\n var rows = targets.map(function (t) {\n var tone = t.action === 'unlink' ? 'warn' : t.action === 'keep' ? 'info' : '';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(t.action === 'unlink' ? '将清理' : t.action === 'keep' ? '保留' : '无动作') + '</span><span class=\"mono\">' + esc(t.dir) + '</span><span class=\"faint\">' + esc(t.note || '') + '</span></div>';\n }).join('');\n var body = '<p class=\"label\">将清理 ' + willUnlink.length + ' 条宿主链接(含死链)' +\n (plan.hubPresent ? ',并把 Hub 目录移入回收站(保留 7 天)' : ';Hub 目录已缺失,仅清理残留') +\n ';保留 ' + kept.length + ' 项宿主真副本 / 外部链接。</p>' +\n '<div class=\"result-list\">' + (rows || '<div class=\"faint\">没有需要处理的宿主目录。</div>') + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入完整技能名确认:' + esc(slug) + '</label><input id=\"removeConfirmSlug\" placeholder=\"' + esc(slug) + '\"></div>';\n confirmModal('删除 Hub 技能 ' + slug + '?', body, '删除', true, async function () {\n var typed = ($('#removeConfirmSlug') && $('#removeConfirmSlug').value || '').trim();\n if (typed !== slug) { toast('技能名不匹配'); return; }\n var r = await api('/api/skills/remove', { body: { slug: slug, confirm: 'remove', confirmSlug: typed } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.verdict === 'cleanup-only' ? '已清理残留(Hub 目录原本缺失)' : '已删除 ' + slug + '(目录已入回收站)');\n loadOverview();\n });\n });\n\n async function loadHosts() {\n var d = await api('/api/hosts');\n if (d.error) { toast(d.error); return; }\n state.hosts = d;\n var s = d.summary.states || {};\n $('#hostSummary').textContent = '可用 ' + (s.available || 0) + ' · 残留 ' + (s.orphan || 0) + ' · 未创建 ' + (s.missing || 0) + ' · 仅标记 ' + (s.markerOnly || 0) + (s.ignored ? ' · 已忽略 ' + s.ignored : '') + (d.summary.excluded ? ' · 不接管 ' + d.summary.excluded : '') + ' · 链接 ' + d.summary.links + '(异常 ' + d.summary.brokenLinks + ')';\n renderHostStateChips();\n renderHosts();\n var marks = d.unpaired || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + ' · 未发现技能目录</div></div><span class=\"badge info\">仅标记</span></div>';\n }).join('') : '<div class=\"empty\">没有未配对的宿主标记。</div>';\n renderHostExcluded(d.excluded || []);\n }\n function renderHostExcluded(list) {\n $('#hostExcludedWrap').hidden = list.length === 0;\n $('#hostExcluded').innerHTML = list.map(function (item) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(item.label || item.value) + '</div><div class=\"row-meta mono\">' + esc(item.value) + ' · ' + esc(item.kind === 'agent' ? '智能体' : '目录') + '</div></div><div class=\"row-actions\"><button class=\"btn small\" data-host-include=\"' + esc(item.value) + '\">恢复接管</button></div></div>';\n }).join('');\n }\n function renderHostStateChips() {\n var d = state.hosts; if (!d) return;\n var s = d.summary.states || {};\n var chips = [\n ['', '全部'],\n ['available', '可用 ' + (s.available || 0)],\n ['orphan', '残留 ' + (s.orphan || 0)],\n ['missing', '未创建 ' + (s.missing || 0)],\n ['marker-only', '仅标记 ' + (s.markerOnly || 0)],\n ];\n $('#hostStateChips').innerHTML = chips.map(function (c) {\n return '<button class=\"chip' + (state.hostStateFilter === c[0] ? ' active' : '') + '\" data-host-state=\"' + esc(c[0]) + '\">' + esc(c[1]) + '</button>';\n }).join('');\n }\n function hostStateBadge(h) {\n if (h.bridgeOnly) return '<span class=\"badge info\">桥接</span>';\n var map = { available: ['ok', '可用'], orphan: ['warn', '残留'], missing: ['', '未创建'], 'marker-only': ['info', '仅标记'], ignored: ['', '已忽略'] };\n var item = map[h.state] || ['', h.state || '未知'];\n return '<span class=\"badge ' + item[0] + '\">' + esc(item[1]) + (h.stateSource === 'manual' ? '·手动' : '') + '</span>' + (h.brokenLinks ? ' <span class=\"badge danger\">异常</span>' : '');\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var sf = state.hostStateFilter;\n var rows = (d.hosts || []).filter(function (h) {\n if (sf && h.state !== sf) return false;\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n var overrideIds = {};\n ((d.overrides) || []).forEach(function (o) { overrideIds[String(o.agentId).toLowerCase()] = true; });\n $('#hostRows').innerHTML = rows.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>'\n : (h.detection === 'user-registry' ? '<span class=\"badge info\">用户注册</span>'\n : (h.detection === 'override' ? '<span class=\"badge ok\">目录覆盖</span>'\n : (h.verified ? '<span class=\"badge ok\">已核实</span>'\n : ((h.detection === 'discovered' || h.detection === 'workspace') ? '<span class=\"badge warn\">自动发现</span>' : '<span class=\"badge warn\">映射·未核实</span>'))));\n var action = '';\n if (h.exists && h.state === 'orphan' && !h.bridgeOnly) action += '<button class=\"btn small danger\" data-host-purge=\"' + esc(h.dir) + '\">清理</button> ';\n if (h.state === 'ignored') action += '<button class=\"btn small\" data-host-mark=\"' + esc(h.dir) + '\" data-mark-state=\"available\">标记可用</button> ';\n else if (h.exists && !h.bridgeOnly) action += '<button class=\"btn small\" data-host-mark=\"' + esc(h.dir) + '\" data-mark-state=\"ignored\">忽略</button> ';\n if (h.agentId && !h.bridgeOnly) {\n if (overrideIds[String(h.agentId).toLowerCase()]) action += '<button class=\"btn small\" data-host-clear=\"' + esc(h.agentId) + '\">恢复默认</button> ';\n else action += '<button class=\"btn small\" data-host-edit=\"' + esc(h.agentId) + '\" data-host-dir=\"' + esc(h.dir) + '\" data-host-label=\"' + esc(h.label) + '\">编辑目录</button> ';\n }\n if (!h.bridgeOnly) action += '<button class=\"btn small\" data-host-exclude=\"' + esc(h.agentId || h.dir) + '\">不接管</button> ';\n if (h.detection === 'user-registry') action += '<button class=\"btn small\" data-host-unregister=\"' + esc(h.dir) + '\">移除注册</button>';\n return '<tr><td><div class=\"cell-main\">' + esc(h.label) + '</div><div class=\"cell-sub\">' + esc(h.agentId || (h.known ? '已知宿主' : h.detection || '自动发现')) + '</div></td>' +\n '<td><div class=\"mono\">' + esc(h.dir) + '</div><button class=\"btn small\" data-copy=\"' + esc(h.dir) + '\">复制</button></td>' +\n '<td>' + scopeBadge + '</td>' +\n '<td>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + hostStateBadge(h) + '</td><td>' + action + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostStateChips').addEventListener('click', function (e) {\n var b = e.target.closest('[data-host-state]');\n if (!b) return;\n state.hostStateFilter = b.dataset.hostState;\n renderHostStateChips();\n renderHosts();\n });\n $('#hostAddBtn').addEventListener('click', function () {\n var body = '<p class=\"label\">注册自定义宿主目录:纳入发现 / 分发 / 收编。不创建目录、不改宿主配置。</p>' +\n '<div class=\"section\"><label class=\"label\">目录(必须已存在)</label><input id=\"hostAddDir\" placeholder=\"例如 D:\\\\my-agent\\\\skills\"></div>' +\n '<div class=\"section\"><label class=\"label\">名称(可选,默认目录名)</label><input id=\"hostAddLabel\" placeholder=\"我的宿主\"></div>';\n openModal('添加自定义目录', body, {\n okText: '注册',\n onOk: async function () {\n var dir = ($('#hostAddDir') && $('#hostAddDir').value || '').trim();\n var label = ($('#hostAddLabel') && $('#hostAddLabel').value || '').trim();\n if (!dir) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/add', { body: { dir: dir, label: label } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已注册:' + ((r.entry && r.entry.label) || dir));\n loadHosts();\n },\n });\n });\n async function openHostPurgeModal(dir) {\n var plan = await api('/api/hosts/purge-plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var body = '<p class=\"label\">只删除指向 Hub 的链接(含死链);非 Hub 链接 / 非技能内容随目录保留在回收站(7 天可恢复)。</p>' +\n '<div class=\"result\"><span class=\"badge warn\">将解除链接</span><b>' + esc(plan.unlinkCount) + '</b><span>条(保留 ' + esc(plan.keptLinks) + ' 非 Hub 链接 / ' + esc(plan.keptEntries) + ' 非链接内容)</span></div>' +\n '<div class=\"mono\">' + esc(dir) + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入 purge 确认执行:</label><input id=\"hostPurgeConfirm\" placeholder=\"purge\"></div>';\n confirmModal('清理残留目录?', body, '清理(移入回收站)', true, async function () {\n var typed = ($('#hostPurgeConfirm') && $('#hostPurgeConfirm').value || '').trim();\n if (typed !== 'purge') { toast('确认串不匹配(输入 purge)'); return; }\n var r = await api('/api/hosts/purge', { body: { dir: dir, confirm: 'purge' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清理:解除 ' + r.unlinkCount + ' 条链接;回收站保留 7 天');\n loadHosts();\n });\n }\n function openHostEditModal(agentId, dir, label) {\n var body = '<p class=\"label\">覆盖「' + esc(label) + '」的技能目录(写入 hosts.json,不改宿主配置)。被覆盖的旧目录不再进入默认发现 / 链接。</p>' +\n '<div class=\"section\"><label class=\"label\">新目录(必须已存在)</label><input id=\"hostEditDir\" value=\"' + esc(dir) + '\"></div>';\n openModal('编辑目录 · ' + esc(agentId), body, {\n okText: '保存覆盖',\n onOk: async function () {\n var value = ($('#hostEditDir') && $('#hostEditDir').value || '').trim();\n if (!value) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/set', { body: { agentId: agentId, dir: value } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已覆盖目录');\n loadHosts();\n },\n });\n }\n $('#hostRows').addEventListener('click', async function (e) {\n var copyBtn = e.target.closest('[data-copy]');\n if (copyBtn) { copyText(copyBtn.dataset.copy); return; }\n var purgeBtn = e.target.closest('[data-host-purge]');\n if (purgeBtn) { openHostPurgeModal(purgeBtn.dataset.hostPurge); return; }\n var editBtn = e.target.closest('[data-host-edit]');\n if (editBtn) {\n openHostEditModal(editBtn.dataset.hostEdit, editBtn.dataset.hostDir, editBtn.dataset.hostLabel);\n return;\n }\n var clearBtn = e.target.closest('[data-host-clear]');\n if (clearBtn) {\n var clearAgent = clearBtn.dataset.hostClear;\n confirmModal('恢复默认目录?', '<p class=\"label\">删除目录覆盖,恢复内置映射。</p><div class=\"mono\">' + esc(clearAgent) + '</div>', '恢复默认', false, async function () {\n var r = await api('/api/hosts/set', { body: { agentId: clearAgent, clear: true } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已恢复内置映射');\n loadHosts();\n });\n return;\n }\n var excludeBtn = e.target.closest('[data-host-exclude]');\n if (excludeBtn) {\n var excludeTarget = excludeBtn.dataset.hostExclude;\n confirmModal('加入「不接管」名单?', '<p class=\"label\">该宿主 / 目录将从发现、显示、链接三层全部跳过;已建立的链接不受影响(可用 CLI hub unlink 清理)。</p><div class=\"mono\">' + esc(excludeTarget) + '</div>', '不接管', false, async function () {\n var r = await api('/api/hosts/exclude', { body: { target: excludeTarget } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已加入不接管名单');\n loadHosts();\n });\n return;\n }\n var markBtn = e.target.closest('[data-host-mark]');\n if (markBtn) {\n var r = await api('/api/hosts/mark', { body: { dir: markBtn.dataset.hostMark, state: markBtn.dataset.markState } });\n if (r.error) { toast(r.error); return; }\n toast('已标记:' + markBtn.dataset.markState);\n loadHosts();\n return;\n }\n var unregBtn = e.target.closest('[data-host-unregister]');\n if (unregBtn) {\n var dir = unregBtn.dataset.hostUnregister;\n confirmModal('移除注册?', '<p class=\"label\">只移除自定义宿主注册,不删除目录、不改宿主配置。</p><div class=\"mono\">' + esc(dir) + '</div>', '移除注册', false, async function () {\n var r = await api('/api/hosts/remove', { body: { dir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已移除注册(目录保留)');\n loadHosts();\n });\n }\n });\n $('#hostExcluded').addEventListener('click', async function (e) {\n var includeBtn = e.target.closest('[data-host-include]');\n if (!includeBtn) return;\n var r = await api('/api/hosts/include', { body: { target: includeBtn.dataset.hostInclude } });\n if (r.error) { toast(r.error); return; }\n toast('已恢复接管');\n loadHosts();\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链接</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n var action = h.bridgeOnly ? '' : '<button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button>';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + ' ' + scopeBadge + '</div><div class=\"row-meta mono\">' + esc(h.dir) + '</div><div class=\"row-meta\">目录技能 ' + esc(h.skillCount || 0) + ' · 指向 Hub ' + esc(h.hubLinks || 0) + (h.brokenLinks ? ' · 异常 ' + h.brokenLinks : '') + '</div></div><div class=\"row-actions\">' + action + '</div></div>';\n }).join('') : '<div class=\"empty\">尚未发现可用的宿主技能目录。</div>';\n var rows = links.links || [];\n $('#linkEmpty').hidden = rows.length > 0;\n $('#linkRows').innerHTML = rows.map(function (l) {\n var tone = l.status === 'ok' ? 'ok' : l.status === 'broken' ? 'danger' : 'warn';\n return '<tr><td><div class=\"cell-main\">' + esc(l.slug) + '</div><div class=\"cell-sub\">' + esc(l.label || l.agent || '-') + '</div></td>' +\n '<td class=\"mono\">' + esc(l.dir) + '</td><td class=\"mono\">' + esc(l.target) + '</td>' +\n '<td><span class=\"badge ' + tone + '\">' + esc(l.status) + '</span></td>' +\n '<td><button class=\"btn small danger\" data-unlink=\"' + esc(l.target) + '\" data-dir=\"' + esc(l.dir) + '\" data-slug=\"' + esc(l.slug) + '\">解除</button></td></tr>';\n }).join('');\n }\n $('#linkTargets').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-link-plan]');\n if (!b) return;\n var dir = b.dataset.linkPlan;\n var plan = await api('/api/links/plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var would = plan.results.filter(function (r) { return r.status === 'would-link'; });\n var converge = plan.results.filter(function (r) { return r.status === 'would-converge'; });\n var skipped = plan.results.filter(function (r) { return r.status === 'skipped'; });\n var conflicts = plan.results.filter(function (r) { return r.status === 'conflict'; });\n var convergeMoves = converge.reduce(function (n, r) { return n + ((r.moves && r.moves.length) || 0); }, 0);\n var linkable = would.length + converge.length;\n var body = '<p class=\"label\">目标:' + esc(plan.label || '') + ' · <span class=\"mono\">' + esc(dir) + '</span></p>' +\n '<div class=\"result\"><span class=\"badge ok\">将建立</span><b>' + linkable + '</b><span>个链接</span></div>' +\n (converge.length ? '<div class=\"result\"><span class=\"badge warn\">将收敛</span><b>' + convergeMoves + '</b><span>份旧副本 → 回收站(保留 7 天,可恢复):' + esc(converge.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (skipped.length ? '<div class=\"result\"><span class=\"badge warn\">跳过</span><b>' + skipped.length + '</b><span>项(Hub 版本旧于宿主 / 版本无法解析):' + esc(skipped.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (conflicts.length ? '<div class=\"result\"><span class=\"badge warn\">跳过冲突</span><b>' + conflicts.length + '</b><span>个同名目录 / 链接(默认不覆盖)</span></div>' : '') +\n (linkable ? '<details><summary>查看明细</summary><div class=\"variants\">' + would.concat(converge).map(function (r) { return '<div class=\"variant\"><span class=\"mono\">' + esc(r.slug) + '</span></div>'; }).join('') + '</div></details>' : '<p class=\"faint\">没有可建立的链接。</p>');\n openModal('链接预览', body, {\n okText: linkable ? '执行链接' : null,\n onOk: async function () {\n var r = await api('/api/links/apply', { body: { targetDir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var done = (r.results || []).filter(function (x) { return x.status === 'linked'; }).length;\n var moved = (r.results || []).reduce(function (n, x) { return n + ((x.moved && x.moved.length) || 0); }, 0);\n toast('已建立 ' + done + ' 个链接' + (moved ? ';收敛 ' + moved + ' 份旧副本(回收站 7 天)' : ''));\n loadLinks();\n },\n });\n });\n $('#linkRows').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-unlink]');\n if (!b) return;\n var target = b.dataset.unlink;\n var dir = b.dataset.dir;\n var slug = b.dataset.slug;\n confirmModal('解除链接 ' + slug + '?', '<p class=\"label\">只删除宿主目录中的链接,Hub 真源保留。目标不是指向 Hub 的链接时会 fail-closed 拒绝。</p><div class=\"mono\">' + esc(target) + '</div>', '解除链接', true, async function () {\n var r = await api('/api/links/remove', { body: { targetDir: dir, slugs: [slug], confirm: 'unlink' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已解除 ' + slug);\n loadLinks();\n });\n });\n\n async function loadRecords() {\n var d = await api('/api/records?limit=200');\n if (d.error) { toast(d.error); return; }\n state.records = d;\n var valid = (d.snapshots || []).filter(function (s) { return s.valid; }).length;\n $('#recordsInfo').textContent = '快照 ' + valid + ' / ' + (d.snapshots || []).length + ' · 证据 ' + (d.evidence.entries || []).length + ' · 审计 ' + (d.audit.entries || []).length;\n renderSnapshots();\n var ev = (d.evidence.entries || []).slice().reverse();\n $('#evidenceEmpty').hidden = ev.length > 0;\n $('#evidenceRows').innerHTML = ev.map(function (x) {\n var tone = x.decision === 'ok' ? 'ok' : x.decision === 'fail' ? 'danger' : 'info';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'event') + ' · ' + esc(x.skill || x.slug || '-') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.ts || x.at)) + (x.version ? ' · v' + esc(x.version) : '') + (x.via ? ' · via ' + esc(x.via) : '') + '</div></div><span class=\"badge ' + tone + '\">' + esc(x.decision || '记录') + '</span></div>';\n }).join('');\n var aud = (d.audit.entries || []).slice().reverse();\n $('#auditEmpty').hidden = aud.length > 0;\n $('#auditRows').innerHTML = aud.map(function (x) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'audit') + ' · ' + esc(x.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.at)) + (x.verdict ? ' · ' + esc(x.verdict) : '') + (x.sourceHost ? ' · ' + esc(x.sourceHost) : '') + '</div></div></div>';\n }).join('');\n }\n function renderSnapshots() {\n var d = state.records; if (!d) return;\n var rows = d.snapshots || [];\n $('#snapshotEmpty').hidden = rows.length > 0;\n $('#snapshotRows').innerHTML = rows.map(function (s) {\n var canRollback = s.valid && s.source;\n return '<tr><td><div class=\"cell-main\">' + esc(s.slug) + '</div><div class=\"cell-sub\">' + esc(s.name) + '</div></td>' +\n '<td>v' + esc(s.version || '-') + '</td><td>' + esc(fmtTime(s.createdAt || s.mtime_ms)) + '</td><td class=\"mono\">' + esc(s.source || '-') + '</td>' +\n '<td>' + (s.valid ? '<span class=\"badge ok\">可用</span>' : '<span class=\"badge danger\">不可用</span>') + (s.legacy ? ' <span class=\"badge warn\">旧格式</span>' : '') + (s.reason ? '<div class=\"cell-sub\">' + esc(s.reason) + '</div>' : '') + '</td>' +\n '<td>' + (canRollback ? '<button class=\"btn small danger\" data-rollback=\"' + esc(s.path) + '\" data-slug=\"' + esc(s.slug) + '\" data-version=\"' + esc(s.version || '') + '\" data-source=\"' + esc(s.source || '') + '\">回滚</button>' : '<span class=\"faint\">CLI 处理</span>') + '</td></tr>';\n }).join('');\n }\n document.querySelectorAll('#view-records [data-tab]').forEach(function (b) {\n b.onclick = function () {\n state.tab = b.dataset.tab;\n Array.prototype.forEach.call(document.querySelectorAll('#view-records [data-tab]'), function (x) { x.classList.toggle('primary', x === b); });\n $('#recSnapshots').hidden = state.tab !== 'snapshots';\n $('#recEvidence').hidden = state.tab !== 'evidence';\n $('#recAudit').hidden = state.tab !== 'audit';\n };\n });\n document.querySelector('#view-records [data-tab=\"snapshots\"]').classList.add('primary');\n $('#snapshotRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-rollback]');\n if (!b) return;\n var slug = b.dataset.slug, snap = b.dataset.rollback, source = b.dataset.source;\n confirmModal('回滚 ' + slug + ' 到 v' + b.dataset.version + '?', '<p class=\"label\">将用快照覆盖安装来源目录;恢复后自动体检并写入证据。</p><div class=\"mono\">' + esc(source) + '</div>', '执行回滚', true, async function () {\n var r = await api('/api/rollback/apply', { body: { slug: slug, snapshot: snap, confirm: 'rollback' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.ok ? '回滚完成(doctor 通过)' : '回滚完成,但体检未通过');\n loadRecords();\n loadOverview();\n });\n });\n\n $('#routeBtn').onclick = runRoute;\n $('#routeInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') runRoute(); });\n async function runRoute() {\n var request = $('#routeInput').value.trim();\n if (!request) { toast('先输入一句需求'); return; }\n var seq = showLoad('正在生成路由与编排…');\n $('#routeBtn').disabled = true;\n var d = await api('/api/route?request=' + encodeURIComponent(request));\n hideLoad(seq);\n $('#routeBtn').disabled = false;\n if (d.error) { toast(d.error); return; }\n var skills = (d.skills || []).map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + s.order + '. ' + esc(s.slug) + ' <span class=\"badge ' + (s.installed ? 'ok' : 'warn') + '\">' + esc(s.installed ? '已装' + (s.version ? ' v' + s.version : '') : '缺失') + '</span></div><div class=\"row-meta\">' + esc(s.role || '') + '</div></div></div>';\n }).join('');\n var missing = (d.missing_skills || []).length\n ? '<div class=\"section panel\"><h3>缺失技能</h3><div class=\"toolbar\"><span class=\"mono\">' + esc((d.missing_skills || []).map(function (s) { return s.slug; }).join(', ')) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(d.install_command || '') + '\">复制安装命令</button></div><div class=\"note\">安装与信任判断由你决定;元信装前扫描默认启用。</div></div>'\n : '';\n var others = (d.other_skill_candidates || []).length\n ? '<div class=\"section panel\"><h3>其他已装技能候选</h3>' + d.other_skill_candidates.map(function (c) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge info\">v' + esc(c.version) + '</span></div><div class=\"row-meta\">' + esc((c.matched_terms || []).join('、')) + ' · ' + esc(c.description || '') + '</div></div></div>';\n }).join('') + '<div class=\"note\">只读 frontmatter 机械匹配;使用前请自行确认。</div></div>'\n : '';\n $('#routeResult').innerHTML = '<div class=\"panel\"><div class=\"shead\"><h2>' + esc(d.playbook.name) + '</h2><span class=\"badge\">置信度 ' + esc(d.confidence) + '</span></div>' +\n '<div class=\"label\">' + esc(d.playbook.intent) + '</div><div class=\"list\">' + skills + '</div>' +\n '<div class=\"note\">动态路由:' + esc((d.dynamic && d.dynamic.status) || '未启用') + ' · 匹配词:' + esc((d.matched_keywords || []).join('、') || '无') + '</div></div>' + missing + others;\n }\n $('#routeResult').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n /* 高级 CLI:/api/help 单一真源(与 CLI --help 同源) */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function renderCliHelp(d) {\n if (!d || d.error) { $('#cliMeta').textContent = '帮助加载失败'; return; }\n var total = 0;\n (d.groups || []).forEach(function (g) {\n (g.commands || []).forEach(function (c) { total += 1 + (c.subcommands || []).length; });\n });\n $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + (d.options || []).length;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.items || []).map(function (item) {\n var meta = findHelpEntry(d.groups, item.cmd) || {};\n return '<div class=\"cmd\"><code>' + esc(item.example) + '</code><span class=\"why\">' + esc(meta.what || '') +\n (item.note ? ' · ' + esc(item.note) : '') + '</span>' +\n (item.danger ? '<span class=\"badge danger\">危险</span>' : '') +\n '<button class=\"btn small\" data-copy-cmd=\"' + esc(item.example) + '\">复制</button></div>';\n }).join('') + '</div>';\n }).join('');\n var full = (d.groups || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.commands || []).map(function (c) {\n var opts = (c.options || []).length\n ? '<div class=\"note\">常用选项: ' + c.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'\n : '';\n var subs = (c.subcommands || []).map(function (s) {\n return '<div class=\"cmd\"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class=\"why\">' + esc(s.what || '') + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc('yotta-skills ' + s.usage) + '\">复制</button></div>';\n }).join('');\n return '<details class=\"cmd-detail\"><summary><code>' + esc('yotta-skills ' + c.usage) + '</code> <span class=\"why\">' + esc(c.what || '') + '</span></summary>' +\n (c.when ? '<div class=\"note\">' + esc(c.when) + '</div>' : '') + opts + subs + '</details>';\n }).join('') + '</div>';\n }).join('');\n full += '<div class=\"cmd-group\"><h3>完整选项</h3>' + (d.options || []).map(function (o) {\n return '<div class=\"cmd\"><code>' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</code><span class=\"why\">' + esc(o.what || '') +\n (o.when ? ' · ' + esc(o.when) : '') + (o.caution ? ' · ⚠ ' + esc(o.caution) : '') + '</span></div>';\n }).join('') + '</div>';\n $('#cliFull').innerHTML = full;\n }\n api('/api/help').then(renderCliHelp);\n document.addEventListener('click', function (e) {\n var b = e.target.closest('#view-cli [data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n (async function boot() {\n var s = await api('/api/status');\n if (s && !s.error) $('#verLine').textContent = 'v' + s.version + ' · ' + (s.standard || '');\n else $('#verLine').textContent = '未连接本地服务';\n runLoad('overview');\n })();\n})();\n</script>\n</body>\n</html>\n";
63
67
  // @generated view-html:end
64
68
 
65
69
  function loadManifest() {
@@ -162,6 +166,7 @@ function parseArgs(argv) {
162
166
  hub: null, all: false, includeDiscovered: false, inPlace: false, allowUnverified: false,
163
167
  from: null, include: [], as: null, skillsCli: null,
164
168
  scan: false, apply: false,
169
+ clear: false,
165
170
  port: null,
166
171
  };
167
172
  const positionals = [];
@@ -213,6 +218,11 @@ function parseArgs(argv) {
213
218
  else if (a === '--domain') opts.domain = take('--domain').toLowerCase();
214
219
  else if (a === '--installed-only') opts.installedOnly = true;
215
220
  else if (a === '--hub') opts.hub = take('--hub');
221
+ else if (a === '--state') opts.state = take('--state').toLowerCase();
222
+ else if (a === '--label') opts.label = take('--label');
223
+ else if (a === '--id') opts.id = take('--id').toLowerCase();
224
+ else if (a === '--clear') opts.clear = true;
225
+ else if (a === '--purge') opts.purge = true;
216
226
  else if (a === '--all') opts.all = true;
217
227
  else if (a === '--include-discovered') opts.includeDiscovered = true;
218
228
  else if (a === '--in-place') opts.inPlace = true;
@@ -255,8 +265,8 @@ function parseArgs(argv) {
255
265
  opts.rest.push(p);
256
266
  continue;
257
267
  }
258
- if (p === 'install' || p === 'update' || p === 'doctor' || p === 'rollback' || p === 'hook'
259
- || p === 'hub' || p === 'view'
268
+ if (p === 'install' || p === 'install-self' || p === 'update' || p === 'doctor' || p === 'rollback'
269
+ || p === 'hook' || p === 'hub' || p === 'view' || p === 'where'
260
270
  || p === 'usage' || p === 'decide-memory') {
261
271
  if (opts.command && opts.command !== p) die('命令冲突:' + opts.command + ' 与 ' + p);
262
272
  opts.command = p;
@@ -266,6 +276,9 @@ function parseArgs(argv) {
266
276
  }
267
277
  // 直接给 slug 且无命令 → 视为 install 单个/多个
268
278
  if (!opts.command && opts.rest.length > 0) opts.command = 'install';
279
+ // 原始位置参数副本:技能 slug 统一小写匹配,但目录路径(hub hosts add/remove 等)
280
+ // 必须保留原始大小写(Windows 路径大小写不敏感但显示 / 台账需要原样)。
281
+ opts.rawRest = opts.rest.slice();
269
282
  opts.skills = opts.rest.map(s => s.toLowerCase());
270
283
  if (opts.command === 'hub') {
271
284
  opts.hubAction = opts.skills[0] || 'status';
@@ -280,6 +293,13 @@ function parseArgs(argv) {
280
293
  if (opts.port !== null && opts.command !== 'view') {
281
294
  die('--port 只能与 view 一起使用', 2, '示例:yotta-skills view --port 8789。');
282
295
  }
296
+ if (opts.state && !['available', 'orphan', 'missing', 'marker-only', 'ignored'].includes(opts.state)) {
297
+ die('未知 --state: ' + opts.state, 2,
298
+ '可用: available(可用)/ orphan(残留)/ missing(未创建)/ marker-only(仅标记)/ ignored(已忽略)。');
299
+ }
300
+ if (opts.state && !(opts.command === 'hub' && (opts.hubAction === 'hosts' || opts.hubAction === 'discover'))) {
301
+ die('--state 只能与 hub hosts 一起使用', 2, '示例:yotta-skills hub hosts --state orphan。');
302
+ }
283
303
  if ((opts.all || opts.includeDiscovered || opts.inPlace || opts.allowUnverified || opts.from || opts.include.length || opts.as || opts.scan || opts.apply)
284
304
  && opts.command !== 'hub') {
285
305
  die('--all / --include-discovered / --in-place / --allow-unverified / --from / --include / --as / --scan / --apply 只能与 hub 命令一起使用', 2,
@@ -372,7 +392,7 @@ function readInstalledVersion(dir) {
372
392
  const f = path.join(dir, 'SKILL.md');
373
393
  try {
374
394
  const text = fs.readFileSync(f, 'utf8');
375
- const m = text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
395
+ const m = text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)/m);
376
396
  return m ? m[1] : null;
377
397
  } catch (_) { return null; }
378
398
  }
@@ -413,7 +433,7 @@ function readInstalledMeta(dir) {
413
433
  try {
414
434
  var text = fs.readFileSync(f, 'utf8');
415
435
  var name = (text.match(/^name:\s*(.+)$/m) || [])[1];
416
- var version = (text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m) || [])[1];
436
+ var version = (text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)/m) || [])[1];
417
437
  return { name: name ? name.trim() : null, version: version || null };
418
438
  } catch (e) { return { name: null, version: null }; }
419
439
  }
@@ -463,6 +483,7 @@ async function runUpdateCheck(opts, dest) {
463
483
  updatable: cachedRows.filter(function (x) { return x.hasUpdate; }),
464
484
  updates: (target && target.last_result && target.last_result.updates) || 0,
465
485
  latest: (target && target.last_result && target.last_result.latest) || 0,
486
+ localAhead: (target && target.last_result && target.last_result.localAhead) || 0,
466
487
  failed: 0,
467
488
  nonFamily: (target && target.last_result && target.last_result.nonFamily) || 0,
468
489
  cache: target,
@@ -477,13 +498,14 @@ async function runUpdateCheck(opts, dest) {
477
498
  rows: cachedRows,
478
499
  updates: cachedPayload.updates,
479
500
  latest: cachedPayload.latest,
501
+ localAhead: cachedPayload.localAhead,
480
502
  failed: 0,
481
503
  nonFamily: cachedPayload.nonFamily,
482
504
  };
483
505
  }
484
506
  var installed = scanInstalledSlugs(dest);
485
507
  if (installed.length === 0) {
486
- var emptyResult = { updates: 0, latest: 0, failed: 0, nonFamily: 0, rows: [], errors: [] };
508
+ var emptyResult = { updates: 0, latest: 0, localAhead: 0, failed: 0, nonFamily: 0, rows: [], errors: [] };
487
509
  var emptyCacheRecord = null;
488
510
  try {
489
511
  emptyCacheRecord = updateCheckLib.recordCheck(os.homedir(), dest, { result: emptyResult, error: null });
@@ -500,6 +522,7 @@ async function runUpdateCheck(opts, dest) {
500
522
  rows: [],
501
523
  updates: 0,
502
524
  latest: 0,
525
+ localAhead: 0,
503
526
  failed: 0,
504
527
  nonFamily: 0,
505
528
  cache: emptyCacheRecord,
@@ -507,7 +530,7 @@ async function runUpdateCheck(opts, dest) {
507
530
  }
508
531
  var rows = [];
509
532
  var failures = [];
510
- var updates = 0, latest = 0, failed = 0, nonFamily = 0;
533
+ var updates = 0, latest = 0, localAhead = 0, failed = 0, nonFamily = 0;
511
534
  for (var i = 0; i < installed.length; i++) {
512
535
  var it = installed[i];
513
536
  var fam = familySkillFor(it.slug);
@@ -525,22 +548,37 @@ async function runUpdateCheck(opts, dest) {
525
548
  }
526
549
  var current = res.version;
527
550
  var cur = it.version || null;
528
- var isUp = (cur === current);
529
- if (isUp) {
530
- latest++;
531
- say(' ✔ ' + it.slug.padEnd(22) + '已最新(本地 v' + cur + ')');
532
- } else {
551
+ var relation = versionRelation(cur, current);
552
+ if (relation === 'upgrade') {
533
553
  updates++;
534
554
  say(' ➤ ' + it.slug.padEnd(22) + '有更新:本地 v' + (cur || '未知') + ' -> 最新 v' + current);
535
555
  if (fam.version && fam.version !== current) {
536
556
  say(' ⚠ 清单 skills.json 记为 v' + fam.version + ',与 npm 最新 v' + current + ' 不一致(需同步清单)');
537
557
  }
538
- }
539
- rows.push({ slug: it.slug, installed: cur, latest: current, pkg: fam.pkg, hasUpdate: !isUp, family: fam });
558
+ } else if (relation === 'equal') {
559
+ latest++;
560
+ say(' ✔ ' + it.slug.padEnd(22) + '已最新(本地 v' + cur + ')');
561
+ } else if (relation === 'local-ahead') {
562
+ localAhead++;
563
+ say(' ⏸ ' + it.slug.padEnd(22) + '本地领先:本地 v' + cur + ' > 最新 v' + current + '(保留,不计入更新)');
564
+ } else {
565
+ say(' ? ' + it.slug.padEnd(22) + '无法比较版本(本地 v' + (cur || '未知') + ' / 最新 v' + current + '),保留');
566
+ }
567
+ rows.push({
568
+ slug: it.slug,
569
+ installed: cur,
570
+ latest: current,
571
+ pkg: fam.pkg,
572
+ hasUpdate: relation === 'upgrade',
573
+ localAhead: relation === 'local-ahead',
574
+ relation: relation,
575
+ family: fam,
576
+ });
540
577
  }
541
578
  var result = {
542
579
  updates: updates,
543
580
  latest: latest,
581
+ localAhead: localAhead,
544
582
  failed: failed,
545
583
  nonFamily: nonFamily,
546
584
  rows: rows,
@@ -570,15 +608,16 @@ async function runUpdateCheck(opts, dest) {
570
608
  updatable: updatable,
571
609
  updates: updates,
572
610
  latest: latest,
611
+ localAhead: localAhead,
573
612
  failed: failed,
574
613
  nonFamily: nonFamily,
575
614
  cache: cacheRecord,
576
615
  error: lastError,
577
616
  }, null, 2));
578
617
  }
579
- return { code: 0, scheduled: true, due: true, checked: true, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
618
+ return { code: 0, scheduled: true, due: true, checked: true, rows: rows, updates: updates, latest: latest, localAhead: localAhead, failed: failed, nonFamily: nonFamily };
580
619
  }
581
- say('汇总: 有更新 ' + updates + ' / 已最新 ' + latest + ' / 检查失败 ' + failed + ' / 非家族跳过 ' + nonFamily);
620
+ say('汇总: 有更新 ' + updates + ' / 本地领先 ' + localAhead + ' / 已最新 ' + latest + ' / 检查失败 ' + failed + ' / 非家族跳过 ' + nonFamily);
582
621
  for (var lineIndex = 0; lineIndex < lines.length; lineIndex++) out(lines[lineIndex]);
583
622
  if (opts.json) {
584
623
  out(JSON.stringify({
@@ -586,13 +625,14 @@ async function runUpdateCheck(opts, dest) {
586
625
  updatable: updatable,
587
626
  updates: updates,
588
627
  latest: latest,
628
+ localAhead: localAhead,
589
629
  failed: failed,
590
630
  nonFamily: nonFamily,
591
631
  cache: cacheRecord,
592
632
  error: lastError,
593
633
  }, null, 2));
594
634
  }
595
- return { code: code, scheduled: false, due: true, checked: true, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
635
+ return { code: code, scheduled: false, due: true, checked: true, rows: rows, updates: updates, latest: latest, localAhead: localAhead, failed: failed, nonFamily: nonFamily };
596
636
  }
597
637
 
598
638
  async function runUpdateAuto(opts, dest) {
@@ -603,7 +643,11 @@ async function runUpdateAuto(opts, dest) {
603
643
  return { code: 1 };
604
644
  }
605
645
  if (r.updates === 0) {
606
- out('全部已最新,无需更新。');
646
+ if (r.localAhead > 0) {
647
+ out('本地领先 ' + r.localAhead + ' 个(保留不降级);其余已最新,无需更新。');
648
+ } else {
649
+ out('全部已最新,无需更新。');
650
+ }
607
651
  return { code: 0 };
608
652
  }
609
653
  out('');
@@ -615,7 +659,7 @@ async function runUpdateAuto(opts, dest) {
615
659
  var res = installOne(
616
660
  Object.assign({}, row.family, { version: row.latest }),
617
661
  dest,
618
- Object.assign({}, opts, { force: true, pin: true, skipScan: false }),
662
+ Object.assign({}, opts, { pin: true, skipScan: false }),
619
663
  );
620
664
  if (res.status === 'ok') ok++;
621
665
  else if (res.status === 'skip') ok++;
@@ -626,7 +670,7 @@ async function runUpdateAuto(opts, dest) {
626
670
  }
627
671
  }
628
672
  out('');
629
- out('自动更新汇总: 成功 ' + ok + ' / 失败 ' + failed2);
673
+ out('自动更新汇总: 成功 ' + ok + ' / 失败 ' + failed2 + (r.localAhead > 0 ? ' / 本地领先保留 ' + r.localAhead : ''));
630
674
  if (failed2 === 0) maybeAutoReindex(opts, dest);
631
675
  return { code: failed2 > 0 ? autoExitCode : 0 };
632
676
  }
@@ -898,18 +942,32 @@ const installOne = createInstaller({
898
942
  appendEvidence: evidenceLib.appendEvidence,
899
943
  });
900
944
 
945
+ function summarizeInstallResults(results) {
946
+ return {
947
+ planned: results.filter(r => r.status === 'planned').length,
948
+ install: results.filter(r => r.status === 'planned' && r.planned === 'install').length,
949
+ update: results.filter(r => r.status === 'planned' && r.planned === 'update').length,
950
+ ok: results.filter(r => r.status === 'ok').length,
951
+ skip: results.filter(r => r.status === 'skip').length,
952
+ failed: results.filter(r => r.status === 'fail').length,
953
+ };
954
+ }
955
+
901
956
  function runInstall(opts, dest) {
957
+ const quiet = Boolean(opts.dryRun && opts.json);
902
958
  const skills = opts.hubScope ? selectHubSkills(opts) : selectSkills(opts);
903
959
  if (skills.length === 0) {
904
- out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
960
+ if (!quiet) out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
905
961
  process.exitCode = 2;
906
- return { failed: 1, exitCode: 2 };
962
+ return { failed: 1, exitCode: 2, results: [], summary: summarizeInstallResults([]) };
963
+ }
964
+ if (!quiet) {
965
+ out('yotta-skills(元阁)v' + VERSION + ' —— 安装 ' + skills.length + ' 个技能 -> ' + dest);
966
+ const sample = skills.find((s) => s.version !== 'latest') || null;
967
+ out('版本策略: ' + (opts.pin
968
+ ? 'pin(锁死清单精确版本,默认)' + (skills.some((s) => s.version === 'latest') ? ';特殊家族跟随各自 npm latest' : '')
969
+ : 'range(' + (sample ? skillRange(sample) : 'latest') + ',跟随最新 patch)'));
907
970
  }
908
- out('yotta-skills(元阁)v' + VERSION + ' —— 安装 ' + skills.length + ' 个技能 -> ' + dest);
909
- const sample = skills.find((s) => s.version !== 'latest') || null;
910
- out('版本策略: ' + (opts.pin
911
- ? 'pin(锁死清单精确版本,默认)' + (skills.some((s) => s.version === 'latest') ? ';特殊家族跟随各自 npm latest' : '')
912
- : 'range(' + (sample ? skillRange(sample) : 'latest') + ',跟随最新 patch)'));
913
971
  const results = [];
914
972
  let failed = 0;
915
973
  let exitCode = 0;
@@ -917,75 +975,124 @@ function runInstall(opts, dest) {
917
975
  const r = installOne(s, dest, opts);
918
976
  results.push(r);
919
977
  if (r.status === 'ok') {
920
- if (r.gate && r.gate.mode === 'explicit-unverified') {
921
- out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
922
- }
923
978
  if (s.slug === 'yotta-verify') recordTrustedVerifier(dest);
924
- out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?'));
979
+ if (!quiet) {
980
+ if (r.gate && r.gate.mode === 'explicit-unverified') {
981
+ out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
982
+ }
983
+ out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?'));
984
+ }
985
+ }
986
+ else if (r.status === 'planned') {
987
+ if (!quiet) {
988
+ out(' [' + (r.planned === 'update' ? '将更新' : '将安装') + '] ' +
989
+ s.slug.padEnd(22) + s.name + ' ' + (r.note || ''));
990
+ }
991
+ }
992
+ else if (r.status === 'skip') {
993
+ if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ',v' + r.version + ')');
925
994
  }
926
- else if (r.status === 'skip') out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ',v' + r.version + ')');
927
995
  else {
928
996
  failed++;
929
997
  exitCode = Math.max(exitCode, r.exitCode || 1);
930
- out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
998
+ if (!quiet) out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
999
+ }
1000
+ }
1001
+ const summary = summarizeInstallResults(results);
1002
+ if (!quiet) {
1003
+ out('');
1004
+ if (opts.dryRun) {
1005
+ out('汇总: 将安装/更新 ' + summary.planned + ' / 已是最新 ' + summary.skip + ' / 失败 ' + failed + '(dry-run 预览)');
1006
+ } else {
1007
+ out('汇总: 成功 ' + summary.ok + ' / 跳过(已是最新) ' + summary.skip + ' / 失败 ' + failed + '(共 ' + results.length + ')');
931
1008
  }
932
1009
  }
933
- const ok = results.filter(r => r.status === 'ok').length;
934
- const skip = results.filter(r => r.status === 'skip').length;
935
- out('');
936
- out('汇总: 成功 ' + ok + ' / 跳过(已是最新) ' + skip + ' / 失败 ' + failed + '(共 ' + results.length + ')');
937
1010
  if (failed > 0) process.exitCode = exitCode || 1;
938
- return { failed, exitCode };
1011
+ return { failed, exitCode, results, summary };
939
1012
  }
940
1013
 
941
1014
  function runUpdate(opts, dest) {
1015
+ const quiet = Boolean(opts.dryRun && opts.json);
942
1016
  let skills = opts.hubScope ? selectHubSkills(opts) : selectSkills(opts);
943
- out('yotta-skills(元阁)v' + VERSION + ' —— 增量更新 -> ' + dest);
1017
+ if (!quiet) out('yotta-skills(元阁)v' + VERSION + ' —— 增量更新 -> ' + dest);
944
1018
  if (opts.installedOnly) {
945
1019
  const candidates = skills.length;
946
1020
  skills = skills.filter((s) => readInstalledVersion(path.join(dest, s.slug)) !== null);
947
- out('范围: 仅已安装技能(--installed-only;候选 ' + candidates + ' / 已装 ' + skills.length + ')');
1021
+ if (!quiet) out('范围: 仅已安装技能(--installed-only;候选 ' + candidates + ' / 已装 ' + skills.length + ')');
948
1022
  if (skills.length === 0) {
949
- out('目标目录未发现已安装的元阁家族技能,无动作(退出码 0)。');
950
- return { failed: 0, exitCode: 0 };
1023
+ if (!quiet) out('目标目录未发现已安装的元阁家族技能,无动作(退出码 0)。');
1024
+ return { failed: 0, exitCode: 0, results: [], summary: summarizeInstallResults([]) };
951
1025
  }
952
1026
  } else if (skills.length === 0) {
953
- out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
1027
+ if (!quiet) out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
954
1028
  process.exitCode = 2;
955
- return { failed: 1, exitCode: 2 };
1029
+ return { failed: 1, exitCode: 2, results: [], summary: summarizeInstallResults([]) };
956
1030
  }
957
1031
  const results = [];
958
1032
  let failed = 0;
959
1033
  let exitCode = 0;
1034
+ const pinExact = opts.pin !== false;
960
1035
  for (const s of skills) {
961
1036
  const existing = readInstalledVersion(path.join(dest, s.slug));
962
- if (existing === s.version) {
963
- out(' - ' + s.slug.padEnd(22) + s.name + ' (已是最新,v' + existing + ')');
964
- results.push({ skill: s, status: 'skip', version: existing });
1037
+ const relation = versionRelation(existing, s.version);
1038
+ if (!opts.force && pinExact && relation === 'equal') {
1039
+ if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (已是最新,v' + existing + ')');
1040
+ results.push({ skill: s, status: 'skip', version: existing, note: '已是最新' });
1041
+ continue;
1042
+ }
1043
+ if (!opts.force && pinExact && relation === 'local-ahead') {
1044
+ if (!quiet) {
1045
+ out(' - ' + s.slug.padEnd(22) + s.name + ' (本地领先 v' + existing + ' > 目标 v' + s.version +
1046
+ ',保留不降级;如需强制覆盖用 --force)');
1047
+ }
1048
+ results.push({ skill: s, status: 'skip', version: existing, note: '本地领先,保留不降级' });
1049
+ continue;
1050
+ }
1051
+ if (!opts.force && pinExact && relation === 'unknown') {
1052
+ if (!quiet) {
1053
+ out(' - ' + s.slug.padEnd(22) + s.name + ' (无法比较版本(本地 v' + existing + ' / 目标 v' + s.version +
1054
+ '),保留;如需强制覆盖用 --force)');
1055
+ }
1056
+ results.push({ skill: s, status: 'skip', version: existing, note: '无法比较版本,保留' });
965
1057
  continue;
966
1058
  }
967
1059
  const r = installOne(s, dest, opts);
968
1060
  results.push(r);
969
1061
  if (r.status === 'ok') {
970
- if (r.gate && r.gate.mode === 'explicit-unverified') {
971
- out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
972
- }
973
1062
  if (s.slug === 'yotta-verify') recordTrustedVerifier(dest);
974
- out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?') + (existing ? '(原 v' + existing + ')' : '(新装)'));
1063
+ if (!quiet) {
1064
+ if (r.gate && r.gate.mode === 'explicit-unverified') {
1065
+ out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
1066
+ }
1067
+ out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?') + (existing ? '(原 v' + existing + ')' : '(新装)'));
1068
+ }
1069
+ }
1070
+ else if (r.status === 'planned') {
1071
+ if (!quiet) {
1072
+ out(' [' + (r.planned === 'update' ? '将更新' : '将安装') + '] ' +
1073
+ s.slug.padEnd(22) + s.name + ' ' + (r.note || ''));
1074
+ }
1075
+ }
1076
+ else if (r.status === 'skip') {
1077
+ if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ')');
975
1078
  }
976
- else if (r.status === 'skip') out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ')');
977
1079
  else {
978
1080
  failed++;
979
1081
  exitCode = Math.max(exitCode, r.exitCode || 1);
980
- out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
1082
+ if (!quiet) out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
1083
+ }
1084
+ }
1085
+ const summary = summarizeInstallResults(results);
1086
+ if (!quiet) {
1087
+ out('');
1088
+ if (opts.dryRun) {
1089
+ out('汇总: 将安装/更新 ' + summary.planned + ' / 已是最新 ' + summary.skip + ' / 失败 ' + failed + '(dry-run 预览)');
1090
+ } else {
1091
+ out('汇总: 更新 ' + summary.ok + ' / 已是最新 ' + summary.skip + ' / 失败 ' + failed);
981
1092
  }
982
1093
  }
983
- const ok = results.filter(r => r.status === 'ok').length;
984
- const skip = results.filter(r => r.status === 'skip').length;
985
- out('');
986
- out('汇总: 更新 ' + ok + ' / 已是最新 ' + skip + ' / 失败 ' + failed);
987
1094
  if (failed > 0) process.exitCode = exitCode || 1;
988
- return { failed, exitCode };
1095
+ return { failed, exitCode, results, summary };
989
1096
  }
990
1097
 
991
1098
  // ── doctor / rollback ─────────────────────────────────────────────────────
@@ -1855,6 +1962,10 @@ function hubScopeLine(opts, discovery) {
1855
1962
  function hubTargetDirs(opts, discovery) {
1856
1963
  const targets = [];
1857
1964
  const seen = new Set();
1965
+ // 0.29.1 U2:不接管名单 / 目录覆盖(与发现层同源 hosts.json)。
1966
+ const registry = hostsRegistryLib.readHostsRegistry({ homeDir: os.homedir(), env: process.env });
1967
+ const excludedEntry = (input) => hostsRegistryLib.excludedEntryFor(registry, input);
1968
+ const cleanupOnly = opts.hubAction === 'unlink' || opts.hubAction === 'remove';
1858
1969
  const add = (dir, agentId, label, meta) => {
1859
1970
  if (!dir) return;
1860
1971
  const resolved = path.resolve(dir);
@@ -1875,7 +1986,15 @@ function hubTargetDirs(opts, discovery) {
1875
1986
  if (opts.dir) {
1876
1987
  // Bridge dirs are never link targets, but `unlink --dir` stays available:
1877
1988
  // it is the explicit cleanup path for historical out-of-scope links.
1878
- if (opts.hubAction !== 'unlink' && opts.hubAction !== 'remove' &&
1989
+ if (!cleanupOnly && agentDirsLib.isYottaCodeDir(opts.dir)) {
1990
+ die('YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),不纳入元阁接管', 2,
1991
+ 'YottaCode 目录由它自己管理,元阁不链接其目录。');
1992
+ }
1993
+ if (!cleanupOnly && excludedEntry({ dir: opts.dir })) {
1994
+ die('该目录在「不接管」名单:' + path.resolve(opts.dir), 2,
1995
+ '恢复接管:yotta-skills hub hosts include "' + path.resolve(opts.dir) + '";再重新链接。');
1996
+ }
1997
+ if (!cleanupOnly &&
1879
1998
  agentDirsLib.isBridgeOnlyDir(opts.dir, { homeDir: os.homedir(), env: process.env })) {
1880
1999
  die('该目录是锁 / 数据桥接目录(XDG_STATE_HOME/skills 或 XDG_DATA_HOME/skills),永不作为链接目标', 2,
1881
2000
  '如需链接真实宿主目录,请改用 --agent <id> 或其它 --dir 路径。');
@@ -1889,7 +2008,19 @@ function hubTargetDirs(opts, discovery) {
1889
2008
  die('未收录智能体: ' + opts.agent + '。可用: ' + Object.keys(AGENT_DIRS).join(', ') + ';或改用 --dir <路径>。',
1890
2009
  2, '先运行 yotta-skills hub hosts 查看本机实际技能目录。');
1891
2010
  }
1892
- add(resolveUserDir(info.dirs[0]), opts.agent, info.label, { explicit: true, verified: info.verified !== false });
2011
+ if (!cleanupOnly && (info.neverLink || agentDirsLib.isYottaCodeDir(resolveUserDir(info.dirs[0])))) {
2012
+ die('YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),不纳入元阁接管', 2,
2013
+ 'YottaCode 目录由它自己管理,元阁不链接其目录。');
2014
+ }
2015
+ if (!cleanupOnly && excludedEntry({ agentId: opts.agent })) {
2016
+ die('该智能体在「不接管」名单:' + opts.agent, 2,
2017
+ '恢复接管:yotta-skills hub hosts include ' + opts.agent + ';再重新链接。');
2018
+ }
2019
+ const override = (registry.overrides || [])
2020
+ .find((item) => String(item.agentId).toLowerCase() === opts.agent);
2021
+ const dir = override ? override.dir : resolveUserDir(info.dirs[0]);
2022
+ add(dir, opts.agent, override ? (override.label || info.label) : info.label,
2023
+ { explicit: true, verified: true });
1893
2024
  return targets;
1894
2025
  }
1895
2026
  if (!opts.all) {
@@ -1897,7 +2028,11 @@ function hubTargetDirs(opts, discovery) {
1897
2028
  '示例:yotta-skills hub link --all;或 yotta-skills hub link --agent codex。');
1898
2029
  }
1899
2030
 
1900
- for (const item of universalSkillDirs()) add(item.dir, item.agentId, item.label, { verified: true });
2031
+ for (const item of universalSkillDirs()) {
2032
+ if (agentDirsLib.isYottaCodeDir(item.dir)) continue;
2033
+ if (excludedEntry({ dir: item.dir, agentId: item.agentId })) continue;
2034
+ add(item.dir, item.agentId, item.label, { verified: true });
2035
+ }
1901
2036
  for (const host of discovery.hosts) {
1902
2037
  if (!host.exists) continue;
1903
2038
  if (host.bridgeOnly) continue;
@@ -1937,27 +2072,65 @@ function hubRemoveTargets(opts, discovery, slug, hubDir) {
1937
2072
  return targets;
1938
2073
  }
1939
2074
 
2075
+ const HOST_STATE_LABELS = {
2076
+ available: '可用',
2077
+ orphan: '残留·实体未确认',
2078
+ missing: '未创建',
2079
+ 'marker-only': '仅标记',
2080
+ ignored: '已忽略',
2081
+ };
2082
+
2083
+ function hostStateLabel(host) {
2084
+ const state = HOST_STATE_LABELS[host.state] || host.state || '未知';
2085
+ const manual = host.stateSource === 'manual' ? '·手动' : '';
2086
+ return state + manual;
2087
+ }
2088
+
2089
+ function hostScopeLabel(host) {
2090
+ if (host.bridgeOnly) return '[桥接·不链接]';
2091
+ let scope = '';
2092
+ if (!host.verified) {
2093
+ // 0.29.1 U2:未核实映射 ≠ 自动发现;「自动发现」只用于文件系统扫描结果。
2094
+ scope = (host.detection === 'discovered' || host.detection === 'workspace')
2095
+ ? '·自动发现'
2096
+ : '·映射未核实';
2097
+ }
2098
+ return '[' + hostStateLabel(host) + scope + ']';
2099
+ }
2100
+
2101
+ function hostStateCounts(hosts) {
2102
+ const counts = { available: 0, orphan: 0, 'marker-only': 0, missing: 0, ignored: 0 };
2103
+ for (const host of hosts || []) {
2104
+ counts[host.state] = (counts[host.state] || 0) + 1;
2105
+ }
2106
+ return counts;
2107
+ }
2108
+
1940
2109
  function hubHostLines(discovery) {
1941
2110
  const lines = [];
1942
2111
  const existing = discovery.hosts.filter((host) => host.exists);
1943
- const missing = discovery.hosts.filter((host) => !host.exists);
2112
+ const markerOnly = discovery.hosts.filter((host) => !host.exists && host.state === 'marker-only');
2113
+ const missing = discovery.hosts.filter((host) => !host.exists && host.state !== 'marker-only');
2114
+ const shown = new Set();
1944
2115
  for (const host of existing) {
1945
- const marker = host.bridgeOnly ? '·' : host.verified ? '✔' : '?';
1946
- const scope = host.bridgeOnly
1947
- ? ' [桥接·不链接]'
1948
- : host.verified
1949
- ? ' [已核实]'
1950
- : host.known ? ' [未核实·默认不链]' : ' [自动发现·默认不链]';
2116
+ shown.add(String(host.label).toLowerCase());
2117
+ const marker = host.bridgeOnly ? '·' : host.state === 'available' ? '✔' : host.state === 'ignored' ? '·' : '△';
1951
2118
  lines.push(' ' + marker + ' ' +
1952
2119
  String(host.label).padEnd(28) + host.dir.padEnd(64) +
1953
- String(host.skillCount).padStart(3) + ' 个技能' +
1954
- scope);
2120
+ String(host.skillCount).padStart(3) + ' 个技能 ' +
2121
+ hostScopeLabel(host));
1955
2122
  }
1956
- const noSkillDir = discovery.installed.filter((item) => !item.hasSkillsDir);
2123
+ for (const host of markerOnly) {
2124
+ shown.add(String(host.label).toLowerCase());
2125
+ lines.push(' △ ' + String(host.label).padEnd(28) + String(host.dir).padEnd(64) +
2126
+ ' 0 个技能 ' + hostScopeLabel(host));
2127
+ }
2128
+ // 0.29.1 U2:CLI / 面板共用单一真源(未配对且无技能目录的标记)。
2129
+ const noSkillDir = agentDiscoveryLib.unpairedMarkers(discovery);
1957
2130
  for (const item of noSkillDir) {
1958
2131
  lines.push(' △ ' + String(item.label).padEnd(28) + '已安装,未发现技能目录 [不适用]');
1959
2132
  }
1960
- return { lines, existing, missing, noSkillDir };
2133
+ return { lines, existing, missing, markerOnly, noSkillDir };
1961
2134
  }
1962
2135
 
1963
2136
  function printHubHosts(discovery, json) {
@@ -1966,16 +2139,19 @@ function printHubHosts(discovery, json) {
1966
2139
  return;
1967
2140
  }
1968
2141
  const view = hubHostLines(discovery);
1969
- const scope = hubScopeSummary(discovery);
2142
+ const counts = hostStateCounts(discovery.hosts);
2143
+ const bridgeCount = view.existing.filter((host) => host.bridgeOnly).length;
1970
2144
  out('yotta-skills(元阁)v' + VERSION + ' —— Hub 宿主发现(文件系统优先,不读元忆)');
1971
- out('本机发现技能目录 ' + view.existing.length + ' 个(已核实 ' + scope.verified +
1972
- ' / 自动发现 ' + scope.discovered + ' / 桥接 ' + scope.bridge + ')/ 已装应用标记 ' + discovery.installed.length +
1973
- ' 个 / 未发现技能目录 ' + view.noSkillDir.length + ' 个');
1974
- out('默认链接范围 = 已核实目录;--include-discovered 显式纳入自动发现目录;锁 / 数据桥接目录永不链接。');
2145
+ out('本机发现技能目录 ' + view.existing.length + ' 个(可用 ' + counts.available +
2146
+ ' / 残留 ' + counts.orphan + ' / 桥接 ' + bridgeCount + ' / 已忽略 ' + counts.ignored +
2147
+ ')/ 仅标记 ' + counts['marker-only'] + ' 个 / 未创建 ' + counts.missing + ' 个');
2148
+ out('默认链接范围 = 已核实目录(含残留,可清理);--include-discovered 显式纳入自动发现目录;锁 / 数据桥接目录永不链接。');
2149
+ out('不接管名单(发现 / 显示 / 链接全部跳过):yotta-skills hub hosts exclude <agentId|目录>;恢复用 include。');
2150
+ out('残留清理:yotta-skills hub hosts remove <目录> --purge(默认预览,--yes 执行)。');
1975
2151
  for (const line of view.lines) out(line);
1976
2152
  if (view.missing.length > 0) {
1977
2153
  out('');
1978
- out('未安装 / 未发现目录 ' + view.missing.length + ' 个(已收录映射,等安装后自动出现)。');
2154
+ out('未创建技能目录 ' + view.missing.length + ' 个(已收录映射,安装后自动出现;yotta-skills hub hosts --state missing 查看)。');
1979
2155
  }
1980
2156
  }
1981
2157
 
@@ -2116,22 +2292,94 @@ function printHubDoctor(payload, json) {
2116
2292
  if (!payload.ok) out('Hub doctor 未通过:请按上面的修复建议处理后重试。');
2117
2293
  }
2118
2294
 
2295
+ /** 0.29.1 U3:hub install / update 成功、失败各写一条 Hub 审计(skip / dry-run 不写;best-effort)。 */
2296
+ function appendHubInstallAudit(hubDir, action, results) {
2297
+ for (const r of results || []) {
2298
+ if (!r || r.status === 'skip' || r.status === 'planned') continue;
2299
+ const entry = {
2300
+ event: action === 'install' ? 'install' : 'update',
2301
+ slug: r.skill && r.skill.slug ? r.skill.slug : null,
2302
+ version: r.version || null,
2303
+ source: 'npm',
2304
+ via: 'hub',
2305
+ ok: r.status === 'ok',
2306
+ };
2307
+ if (r.snapshot) entry.snapshot = r.snapshot;
2308
+ if (r.gate && r.gate.verdict) entry.verdict = r.gate.verdict;
2309
+ if (r.status !== 'ok') entry.error = r.note || 'unknown';
2310
+ try {
2311
+ hubLib.appendAudit(hubDir, entry);
2312
+ } catch (_) { /* best-effort:审计失败不阻断安装 / 更新主流程 */ }
2313
+ }
2314
+ }
2315
+
2119
2316
  function runHub(opts) {
2120
2317
  const hubDir = hubLib.resolveHubDir(opts);
2121
2318
  const action = opts.hubAction || 'status';
2122
2319
  const discovery = agentDiscoveryLib.discoverHosts({ homeDir: os.homedir(), env: process.env });
2123
2320
 
2124
2321
  if (action === 'hosts' || action === 'discover') {
2125
- printHubHosts(discovery, opts.json);
2322
+ const sub = opts.skills[0] || null;
2323
+ if (sub === 'add') { runHostsAdd(opts); return; }
2324
+ if (sub === 'remove') { runHostsRemove(opts); return; }
2325
+ if (sub === 'list') { runHostsList(opts); return; }
2326
+ if (sub === 'mark') { runHostsMark(opts); return; }
2327
+ if (sub === 'exclude') { runHostsExclude(opts); return; }
2328
+ if (sub === 'include') { runHostsInclude(opts); return; }
2329
+ if (sub === 'set') { runHostsSet(opts); return; }
2330
+ if (sub) {
2331
+ die('未知 hub hosts 子命令: ' + sub, 2,
2332
+ '支持 add / remove / list / mark / exclude / include / set;无子命令 = 只读发现。');
2333
+ }
2334
+ const view = opts.state
2335
+ ? { ...discovery, hosts: discovery.hosts.filter((host) => host.state === opts.state) }
2336
+ : discovery;
2337
+ printHubHosts(view, opts.json);
2126
2338
  return;
2127
2339
  }
2128
2340
 
2129
2341
  if (action === 'install' || action === 'update') {
2130
- out('Hub: ' + hubDir);
2131
- out('范围: 全家清单 ' + MANIFEST.length + ' + 特殊家族 ' + hubLib.HUB_FAMILY_EXTRAS.length +
2132
- '(特殊家族跟随各自 npm latest;非元技能不参与更新)');
2342
+ const quiet = Boolean(opts.dryRun && opts.json);
2343
+ if (!quiet) {
2344
+ out('Hub: ' + hubDir);
2345
+ out('范围: 全家清单 ' + MANIFEST.length + ' + 特殊家族 ' + hubLib.HUB_FAMILY_EXTRAS.length +
2346
+ '(特殊家族跟随各自 npm latest;非元技能不参与更新)');
2347
+ if (opts.dryRun) out('模式: dry-run(只读预演;无网络、无写入)');
2348
+ }
2133
2349
  const hubOpts = { ...opts, hubScope: true };
2134
2350
  const result = action === 'install' ? runInstall(hubOpts, hubDir) : runUpdate(hubOpts, hubDir);
2351
+ if (opts.dryRun) {
2352
+ // 0.29.0 D1:预览不更新 Hub 台账(无写入)。
2353
+ if (quiet) {
2354
+ out(JSON.stringify({
2355
+ action,
2356
+ hubDir,
2357
+ dryRun: true,
2358
+ scope: { manifest: MANIFEST.length, extras: hubLib.HUB_FAMILY_EXTRAS.length },
2359
+ skills: (result.results || []).map(function (r) {
2360
+ const isPlanned = r.status === 'planned';
2361
+ const isSkip = r.status === 'skip';
2362
+ return {
2363
+ slug: r.skill.slug,
2364
+ name: r.skill.name,
2365
+ pkg: r.skill.pkg,
2366
+ planned: isPlanned ? r.planned : (isSkip ? 'skip' : 'fail'),
2367
+ version: isPlanned ? r.version : (r.version || null),
2368
+ installedVersion: isPlanned ? (r.installedVersion || null) : (isSkip ? r.version : null),
2369
+ latestUnresolved: Boolean(r.latestUnresolved),
2370
+ spec: specOf(r.skill, opts.pin),
2371
+ note: r.note || null,
2372
+ };
2373
+ }),
2374
+ summary: result.summary || { planned: 0, skip: 0, failed: 0 },
2375
+ }, null, 2));
2376
+ } else {
2377
+ out('');
2378
+ out('(dry-run 未执行任何下载 / 写入;Hub 台账未更新)');
2379
+ }
2380
+ if (result.failed > 0) process.exitCode = result.exitCode || 1;
2381
+ return;
2382
+ }
2135
2383
  const synced = hubLib.syncHubState(hubDir, { manifest: MANIFEST });
2136
2384
  const count = Object.values(synced.state.skills).filter((item) => item.status === 'present').length;
2137
2385
  const external = Object.values(synced.state.skills)
@@ -2141,6 +2389,7 @@ function runHub(opts) {
2141
2389
  out('非元技能 ' + external + ' 个不参与更新(来源无统一安装源,用户自行处理)。');
2142
2390
  }
2143
2391
  out('Hub 台账已更新: ' + count + ' 个技能;标准 ' + hubLib.STANDARD_ID);
2392
+ appendHubInstallAudit(hubDir, action, result.results);
2144
2393
  if (result.failed > 0) process.exitCode = result.exitCode || 1;
2145
2394
  return;
2146
2395
  }
@@ -2334,6 +2583,354 @@ function runHub(opts) {
2334
2583
  '支持 install / update / adopt / refresh / link / unlink / remove / status / hosts / doctor。');
2335
2584
  }
2336
2585
 
2586
+ // ── hub hosts add / remove / list / mark(0.29.0 F2/F3 注册表与手动标记) ─────
2587
+ function hostRegistryOptions(opts) {
2588
+ return { homeDir: os.homedir(), env: process.env, hub: opts.hub };
2589
+ }
2590
+
2591
+ function auditHostEvent(opts, entry) {
2592
+ const hubDir = hubLib.resolveHubDir(opts);
2593
+ if (!fs.existsSync(hubDir)) return;
2594
+ try {
2595
+ hubLib.appendAudit(hubDir, entry);
2596
+ } catch (_) { /* 审计失败不阻断注册 / 清理主流程 */ }
2597
+ }
2598
+
2599
+ /** hub hosts 子命令的原始位置参数(保留路径大小写;slug 版本已小写化)。 */
2600
+ function hostsSubcommandArg(opts, index) {
2601
+ if (opts.rawRest && opts.rawRest.length > index) return opts.rawRest[index];
2602
+ return opts.skills[index] || null;
2603
+ }
2604
+
2605
+ function runHostsAdd(opts) {
2606
+ const dir = hostsSubcommandArg(opts, 2);
2607
+ if (!dir) {
2608
+ die('hub hosts add 需要目录', 2,
2609
+ '示例:yotta-skills hub hosts add "<技能目录>" --label "我的宿主"。');
2610
+ }
2611
+ const registryOpts = hostRegistryOptions(opts);
2612
+ const result = hostsRegistryLib.addHost(registryOpts, { dir, label: opts.label, agentId: opts.id });
2613
+ if (!result.ok) {
2614
+ die(result.error, 2, '确认目录存在且不是 Hub / 锁桥接目录后重试。');
2615
+ }
2616
+ auditHostEvent(opts, {
2617
+ event: 'hosts.add',
2618
+ dir: result.entry.dir,
2619
+ label: result.entry.label,
2620
+ agentId: result.entry.agentId,
2621
+ });
2622
+ if (opts.json) {
2623
+ out(JSON.stringify({
2624
+ action: 'hosts.add',
2625
+ entry: result.entry,
2626
+ registryPath: hostsRegistryLib.hostsRegistryPath(registryOpts),
2627
+ }, null, 2));
2628
+ return;
2629
+ }
2630
+ out('✔ 已注册自定义宿主: ' + result.entry.label);
2631
+ out(' 目录: ' + result.entry.dir);
2632
+ out(' 注册表: ' + hostsRegistryLib.hostsRegistryPath(registryOpts));
2633
+ out(' 已纳入: hub link --all / hub adopt --scan / 面板宿主矩阵(不创建目录、不改宿主配置)。');
2634
+ }
2635
+
2636
+ function runHostsList(opts) {
2637
+ const registryOpts = hostRegistryOptions(opts);
2638
+ const registry = hostsRegistryLib.readHostsRegistry(registryOpts);
2639
+ if (opts.json) {
2640
+ out(JSON.stringify({
2641
+ action: 'hosts.list',
2642
+ registryPath: hostsRegistryLib.hostsRegistryPath(registryOpts),
2643
+ hosts: registry.hosts,
2644
+ excluded: registry.excluded,
2645
+ overrides: registry.overrides,
2646
+ }, null, 2));
2647
+ return;
2648
+ }
2649
+ out('yotta-skills(元阁)v' + VERSION + ' —— 自定义宿主注册表');
2650
+ out('注册表: ' + hostsRegistryLib.hostsRegistryPath(registryOpts));
2651
+ if (registry.hosts.length === 0 && (registry.excluded || []).length === 0 && (registry.overrides || []).length === 0) {
2652
+ out('(空;用 yotta-skills hub hosts add <目录> 注册)');
2653
+ return;
2654
+ }
2655
+ for (const item of registry.hosts) {
2656
+ out(' ' + String(item.label).padEnd(28) + item.dir +
2657
+ (item.manualState ? ' [手动: ' + item.manualState + ']' : ''));
2658
+ }
2659
+ if ((registry.excluded || []).length > 0) {
2660
+ out('');
2661
+ out('不接管名单 ' + registry.excluded.length + ' 个(发现 / 显示 / 链接全部跳过):');
2662
+ for (const item of registry.excluded) {
2663
+ out(' ⊘ ' + String(item.label || item.value).padEnd(28) + item.value);
2664
+ }
2665
+ }
2666
+ if ((registry.overrides || []).length > 0) {
2667
+ out('');
2668
+ out('目录覆盖 ' + registry.overrides.length + ' 个:');
2669
+ for (const item of registry.overrides) {
2670
+ out(' ↳ ' + String(item.label || item.agentId).padEnd(28) + item.dir +
2671
+ '(原: ' + (item.previousDirs || []).join(' / ') + ')');
2672
+ }
2673
+ }
2674
+ }
2675
+
2676
+ function runHostsRemove(opts) {
2677
+ const dir = hostsSubcommandArg(opts, 2);
2678
+ if (opts.purge) {
2679
+ runHostsPurge(opts, dir);
2680
+ return;
2681
+ }
2682
+ if (!dir && !opts.id) {
2683
+ die('hub hosts remove 需要 <目录> 或 --id <agentId>', 2,
2684
+ '示例:yotta-skills hub hosts remove "<技能目录>";残留清理加 --purge(默认预览)。');
2685
+ }
2686
+ const registryOpts = hostRegistryOptions(opts);
2687
+ const result = hostsRegistryLib.removeHost(registryOpts, { dir, id: opts.id });
2688
+ if (!result.ok) {
2689
+ die(result.error, 2, '示例:yotta-skills hub hosts list 查看已注册项。');
2690
+ }
2691
+ auditHostEvent(opts, {
2692
+ event: 'hosts.remove',
2693
+ dir: result.removed.dir,
2694
+ label: result.removed.label,
2695
+ agentId: result.removed.agentId,
2696
+ });
2697
+ if (opts.json) {
2698
+ out(JSON.stringify({ action: 'hosts.remove', removed: result.removed }, null, 2));
2699
+ return;
2700
+ }
2701
+ out('✔ 已移除注册(目录未删除): ' + result.removed.dir);
2702
+ }
2703
+
2704
+ function runHostsMark(opts) {
2705
+ const dir = hostsSubcommandArg(opts, 2);
2706
+ if (!dir) {
2707
+ die('hub hosts mark 需要目录', 2,
2708
+ '示例:yotta-skills hub hosts mark "<技能目录>" --state orphan。');
2709
+ }
2710
+ const state = opts.state;
2711
+ if (!state) {
2712
+ die('hub hosts mark 需要 --state <available|orphan|ignored>', 2,
2713
+ '可用(available)/ 残留(orphan)/ 忽略(ignored)。');
2714
+ }
2715
+ const registryOpts = hostRegistryOptions(opts);
2716
+ const result = hostsRegistryLib.markHost(registryOpts, { dir, state });
2717
+ if (!result.ok) {
2718
+ die(result.error, 2, '手动标记与注册表共用存储(hosts.json)。');
2719
+ }
2720
+ auditHostEvent(opts, { event: 'hosts.mark', dir: result.entry.dir, state });
2721
+ if (opts.json) {
2722
+ out(JSON.stringify({ action: 'hosts.mark', entry: result.entry }, null, 2));
2723
+ return;
2724
+ }
2725
+ out('✔ 已手动标记: ' + result.entry.dir + ' -> ' + state);
2726
+ }
2727
+
2728
+ /** 0.29.1 U2:不接管开关(发现 / 显示 / 链接三层全部跳过,--include-discovered 也不纳入)。 */
2729
+ function runHostsExclude(opts) {
2730
+ const target = hostsSubcommandArg(opts, 2);
2731
+ if (!target) {
2732
+ die('hub hosts exclude 需要 <agentId|目录>', 2,
2733
+ '示例:yotta-skills hub hosts exclude box;或 hub hosts exclude "D:\\my-agent\\skills"。');
2734
+ }
2735
+ const registryOpts = hostRegistryOptions(opts);
2736
+ const result = hostsRegistryLib.excludeHost(registryOpts, { target });
2737
+ if (!result.ok) {
2738
+ die(result.error, 2, '目标可以是已收录 agentId,或技能目录(非 Hub / 非锁桥接 / 非 YottaCode)。');
2739
+ }
2740
+ auditHostEvent(opts, {
2741
+ event: 'hosts.exclude',
2742
+ kind: result.entry.kind,
2743
+ value: result.entry.value,
2744
+ label: result.entry.label,
2745
+ });
2746
+ if (opts.json) {
2747
+ out(JSON.stringify({ action: 'hosts.exclude', entry: result.entry, already: Boolean(result.already) }, null, 2));
2748
+ return;
2749
+ }
2750
+ out((result.already ? '· 已在' : '✔ 已加入') + '「不接管」名单: ' + result.entry.label + '(' + result.entry.value + ')');
2751
+ out(' 发现 / 显示 / 链接三层全部跳过(--include-discovered 也不纳入)。');
2752
+ out(' 恢复接管:yotta-skills hub hosts include ' +
2753
+ (result.entry.kind === 'dir' ? '"' + result.entry.value + '"' : result.entry.value));
2754
+ }
2755
+
2756
+ /** 0.29.1 U2:恢复接管(移出「不接管」名单)。 */
2757
+ function runHostsInclude(opts) {
2758
+ const target = hostsSubcommandArg(opts, 2);
2759
+ if (!target) {
2760
+ die('hub hosts include 需要 <agentId|目录>', 2,
2761
+ '示例:yotta-skills hub hosts include box;或 hub hosts include "D:\\my-agent\\skills"。');
2762
+ }
2763
+ const registryOpts = hostRegistryOptions(opts);
2764
+ const result = hostsRegistryLib.includeHost(registryOpts, { target });
2765
+ if (!result.ok) {
2766
+ die(result.error, 2, '用 yotta-skills hub hosts list 或面板「不接管」名单查看当前条目。');
2767
+ }
2768
+ auditHostEvent(opts, {
2769
+ event: 'hosts.include',
2770
+ kind: result.removed.kind,
2771
+ value: result.removed.value,
2772
+ label: result.removed.label,
2773
+ });
2774
+ if (opts.json) {
2775
+ out(JSON.stringify({ action: 'hosts.include', removed: result.removed }, null, 2));
2776
+ return;
2777
+ }
2778
+ out('✔ 已恢复接管: ' + result.removed.label + '(下次发现 / 链接生效)');
2779
+ }
2780
+
2781
+ /** 0.29.1 U2:目录覆盖(hub hosts set <agentId> --dir <目录>;--clear 恢复默认)。 */
2782
+ function runHostsSet(opts) {
2783
+ const agentId = hostsSubcommandArg(opts, 2);
2784
+ if (!agentId) {
2785
+ die('hub hosts set 需要 <agentId>', 2,
2786
+ '示例:yotta-skills hub hosts set box --dir "D:\\box-agent\\skills";恢复默认加 --clear。');
2787
+ }
2788
+ const registryOpts = hostRegistryOptions(opts);
2789
+ if (opts.clear) {
2790
+ const result = hostsRegistryLib.clearHostOverride(registryOpts, { agentId });
2791
+ if (!result.ok) {
2792
+ die(result.error, 2, '示例:yotta-skills hub hosts set <agentId> --dir <目录>。');
2793
+ }
2794
+ auditHostEvent(opts, { event: 'hosts.set', agentId, cleared: true, dir: result.removed.dir });
2795
+ if (opts.json) {
2796
+ out(JSON.stringify({ action: 'hosts.set', cleared: true, removed: result.removed }, null, 2));
2797
+ return;
2798
+ }
2799
+ out('✔ 已恢复内置映射: ' + agentId);
2800
+ return;
2801
+ }
2802
+ const result = hostsRegistryLib.setHostOverride(registryOpts, { agentId, dir: opts.dir, label: opts.label });
2803
+ if (!result.ok) {
2804
+ die(result.error, 2, '目录需已存在,且不是 Hub / 锁桥接 / YottaCode 目录。');
2805
+ }
2806
+ auditHostEvent(opts, { event: 'hosts.set', agentId, dir: result.entry.dir, label: result.entry.label });
2807
+ if (opts.json) {
2808
+ out(JSON.stringify({ action: 'hosts.set', entry: result.entry }, null, 2));
2809
+ return;
2810
+ }
2811
+ out('✔ 已覆盖目录: ' + result.entry.label + ' -> ' + result.entry.dir);
2812
+ out(' 被覆盖的旧目录不再进入默认发现 / 链接;恢复默认:yotta-skills hub hosts set ' + agentId + ' --clear。');
2813
+ }
2814
+
2815
+ /**
2816
+ * 残留目录清理(F3):默认预览;--yes 执行。核心逻辑在 lib/host-purge.js
2817
+ * (CLI 与面板共用,fail-closed)。
2818
+ */
2819
+ function runHostsPurge(opts, dir) {
2820
+ if (!dir) {
2821
+ die('hub hosts remove --purge 需要显式目录', 2,
2822
+ '示例:yotta-skills hub hosts remove "<技能目录>" --purge(默认预览;确认后加 --yes 执行)。');
2823
+ }
2824
+ const purgeOptions = {
2825
+ hubDir: hubLib.resolveHubDir(opts),
2826
+ dir,
2827
+ homeDir: os.homedir(),
2828
+ env: process.env,
2829
+ hub: opts.hub,
2830
+ };
2831
+ const plan = hostPurgeLib.planHostPurge(purgeOptions);
2832
+ if (!plan.ok) {
2833
+ die(plan.error, 2, '只允许清理残留目录;如确认要清理,先运行 yotta-skills hub hosts mark "<目录>" --state orphan。');
2834
+ }
2835
+ if (!opts.yes) {
2836
+ const payload = {
2837
+ action: 'hosts.purge',
2838
+ dryRun: true,
2839
+ dir: plan.target,
2840
+ state: plan.state,
2841
+ hubDir: plan.hubDir,
2842
+ unlinkCount: plan.linkSlugs.length,
2843
+ keptLinks: plan.keptLinks,
2844
+ keptEntries: plan.keptEntries,
2845
+ trashedTo: plan.trashPath,
2846
+ note: '预览未执行任何变更;确认后加 --yes 执行。',
2847
+ };
2848
+ if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
2849
+ out('yotta-skills(元阁)v' + VERSION + ' —— 残留清理(预览)');
2850
+ out('目录: ' + plan.target);
2851
+ out('状态: 残留(实体未确认)');
2852
+ out('将解除 Hub 链接: ' + plan.linkSlugs.length + ' 条(非 Hub 链接 ' + plan.keptLinks + ' / 非链接内容 ' + plan.keptEntries + ' 保留)');
2853
+ out('将移入回收站: ' + plan.trashPath + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
2854
+ out('(预览未执行任何变更;确认后加 --yes 执行)');
2855
+ return;
2856
+ }
2857
+ const result = hostPurgeLib.applyHostPurge(purgeOptions);
2858
+ if (!result.ok) {
2859
+ die(result.error, 2, '残留清理失败;请检查目录状态后重试。');
2860
+ }
2861
+ const payload = {
2862
+ action: 'hosts.purge',
2863
+ dryRun: false,
2864
+ dir: result.dir,
2865
+ state: result.state,
2866
+ hubDir: plan.hubDir,
2867
+ unlinkCount: result.unlinkCount,
2868
+ keptLinks: result.keptLinks,
2869
+ keptEntries: result.keptEntries,
2870
+ trashedTo: result.trashedTo,
2871
+ restore: '把目录移回原位置即可恢复;回收站保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天。',
2872
+ };
2873
+ if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
2874
+ out('✔ 已清理残留目录: ' + result.dir);
2875
+ out(' 解除 Hub 链接: ' + result.unlinkCount + ' 条(非 Hub 链接 ' + result.keptLinks + ' / 非链接内容 ' + result.keptEntries + ' 随目录保留在回收站)');
2876
+ out(' 回收站: ' + result.trashedTo + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
2877
+ out(' 恢复: 把目录移回原位置即可。');
2878
+ }
2879
+
2880
+ // ── install-self / where:元阁独立安装与位置查看(0.29.0 F1) ───────────────
2881
+ function runSelfInstall(opts) {
2882
+ const result = selfInstallLib.installSelf({
2883
+ dir: opts.dir,
2884
+ dryRun: opts.dryRun,
2885
+ force: opts.force,
2886
+ hub: opts.hub,
2887
+ homeDir: os.homedir(),
2888
+ env: process.env,
2889
+ });
2890
+ if (!result.ok) {
2891
+ die(result.error, result.exitCode || 2,
2892
+ result.hint || '独立安装默认位置 ~/.yottaskills/yotta-skills;用 --dir 指定位置。');
2893
+ }
2894
+ if (opts.json) {
2895
+ out(JSON.stringify(result, null, 2));
2896
+ return;
2897
+ }
2898
+ out('yotta-skills(元阁)v' + VERSION + ' —— 独立安装');
2899
+ out('目标: ' + result.dir);
2900
+ if (result.dryRun) {
2901
+ out('将写入: ' + result.files.join(' / '));
2902
+ out('登记: ' + result.registry);
2903
+ out('(dry-run 未执行任何写入)');
2904
+ return;
2905
+ }
2906
+ out('✔ 已安装运行件: ' + result.files.join(' / '));
2907
+ out('登记: ' + result.registry);
2908
+ out('提示: 直接用 node ' + path.join(result.dir, 'bin', 'yotta-skills.js') + ' 运行;不写宿主配置、不建全局 shim。');
2909
+ }
2910
+
2911
+ function runWhere(opts) {
2912
+ const info = selfInstallLib.whereInfo({
2913
+ hub: opts.hub,
2914
+ homeDir: os.homedir(),
2915
+ env: process.env,
2916
+ });
2917
+ if (opts.json) {
2918
+ out(JSON.stringify(info, null, 2));
2919
+ return;
2920
+ }
2921
+ out('yotta-skills(元阁)v' + VERSION + ' —— 位置');
2922
+ out('运行中 CLI: ' + (info.running || '未知'));
2923
+ if (info.installs.length === 0) {
2924
+ out('独立安装: 未独立安装(经 npx / 宿主副本运行)');
2925
+ } else {
2926
+ for (const item of info.installs) {
2927
+ out('独立安装: ' + item.dir + '(v' + (item.version || '?') + (item.current ? ',当前运行项' : '') + ')');
2928
+ }
2929
+ }
2930
+ out('默认安装位置: ' + info.defaultDir);
2931
+ out('Hub 真源: ' + info.hubDir);
2932
+ }
2933
+
2337
2934
  // ── view:本机技能枢纽面板(127.0.0.1,只读 API + 预览确认动作) ───────────
2338
2935
  function runView(opts) {
2339
2936
  const home = os.homedir();
@@ -2409,6 +3006,14 @@ function main() {
2409
3006
  runHub(opts);
2410
3007
  return;
2411
3008
  }
3009
+ if (command === 'install-self') {
3010
+ runSelfInstall(opts);
3011
+ return;
3012
+ }
3013
+ if (command === 'where') {
3014
+ runWhere(opts);
3015
+ return;
3016
+ }
2412
3017
  if (command === 'rollback' && opts.list) {
2413
3018
  runRollback(opts, null);
2414
3019
  return;
@@ -2416,7 +3021,8 @@ function main() {
2416
3021
  let dest = resolveTargetDir(opts);
2417
3022
  if (!dest && !opts.dryRun) dest = detectProjectDir();
2418
3023
  if (!dest && (command === 'install' || command === 'doctor' || command === 'rollback') && !opts.dryRun) {
2419
- die('未指定目标:请用 --agent <name> 或 --dir <path>(当前目录未检测到项目级技能目录)。', 4, '未收录智能体也可用 --dir 指定其技能目录。');
3024
+ die('未指定目标:请用 --agent <name> 或 --dir <path>(当前目录未检测到项目级技能目录)。', 4,
3025
+ '装到本机 Hub 用 hub install;直装到某智能体用 --agent / --dir;独立安装元阁用 install-self。');
2420
3026
  }
2421
3027
 
2422
3028
  if (opts.dryRun) {
@@ -2436,8 +3042,28 @@ function main() {
2436
3042
  out('版本策略: ' + (opts.pin ? 'pin(锁死)' : 'range(' + skillRange(skills[0]) + ')'));
2437
3043
  out('');
2438
3044
  for (const s of skills) {
2439
- const tag = command === 'update' ? '将检查/更新' : '将安装';
2440
- out(' [' + tag + '] ' + s.slug.padEnd(22) + s.name.padEnd(5) + ' ' + specOf(s, opts.pin).padEnd(52) + ' ' + s.version + ' ' + s.desc);
3045
+ let tag = command === 'update' ? '将检查/更新' : '将安装';
3046
+ let suffix = '';
3047
+ if (command === 'update' && dest && !opts.force && opts.pin !== false) {
3048
+ const existing = readInstalledVersion(path.join(dest, s.slug));
3049
+ const relation = versionRelation(existing, s.version);
3050
+ if (relation === 'equal') {
3051
+ tag = '已最新';
3052
+ suffix = '(跳过,v' + existing + ')';
3053
+ } else if (relation === 'local-ahead') {
3054
+ tag = '本地领先';
3055
+ suffix = '(保留不降级:v' + existing + ' > v' + s.version + ')';
3056
+ } else if (relation === 'unknown') {
3057
+ tag = '无法比较';
3058
+ suffix = '(保留:本地 v' + (existing || '未知') + ')';
3059
+ } else if (relation === 'upgrade') {
3060
+ tag = '将更新';
3061
+ suffix = '(v' + existing + ' -> v' + s.version + ')';
3062
+ } else if (relation === 'install') {
3063
+ tag = '将安装';
3064
+ }
3065
+ }
3066
+ out(' [' + tag + '] ' + s.slug.padEnd(22) + s.name.padEnd(5) + ' ' + specOf(s, opts.pin).padEnd(52) + ' ' + s.version + ' ' + s.desc + suffix);
2441
3067
  }
2442
3068
  out('');
2443
3069
  out('(dry-run 未执行任何下载/写入)');