@yottameta/yotta-skills-plugin 0.29.0 → 0.29.2

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,20 +49,23 @@ 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 hubMigrateLib = require('../lib/hub-migrate');
52
53
  const selfInstallLib = require('../lib/self-install');
54
+ const skillsConfigLib = require('../lib/skills-config');
53
55
  const hostsRegistryLib = require('../lib/hosts-registry');
54
56
  const hostPurgeLib = require('../lib/host-purge');
55
57
  const hubScanLib = require('../lib/hub-scan');
56
58
  const hubViewServerLib = require('../lib/hub-view-server');
57
59
  const { createInstaller, isSafeTarEntry } = require('../lib/install-pipeline');
58
60
  const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
61
+ const { versionRelation } = require('../lib/skills-scan');
59
62
 
60
63
  const PKG_ROOT = path.join(__dirname, '..');
61
- let VERSION = '0.29.0';
64
+ let VERSION = '0.29.2';
62
65
  try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
63
66
 
64
67
  // @generated view-html:start
65
- 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 </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.links + '(异常 ' + d.summary.brokenLinks + ')';\n renderHostStateChips();\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 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 $('#hostRows').innerHTML = rows.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>' : (h.detection === 'user-registry' ? '<span class=\"badge info\">用户注册</span>' : h.verified ? '<span class=\"badge ok\">已核实</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.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 $('#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 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\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";
68
+ 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=\"faint\" id=\"ovHubMeta\"></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 <button class=\"btn small\" id=\"hubConfigBtn\">设置新位置</button>\n <button class=\"btn small\" id=\"hubConfigClearBtn\" hidden>清除覆盖</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 var hubInfo = d.hub || {};\n var sourceNames = { flag: '--hub 参数', env: 'YOTTA_SKILLS_HUB 环境变量', config: 'config.json 配置', default: '默认位置' };\n var metaParts = ['来源: ' + (sourceNames[hubInfo.source] || hubInfo.source || '默认位置')];\n if (hubInfo.configured) metaParts.push('配置覆盖: ' + hubInfo.configured);\n if (hubInfo.restartRequired) metaParts.push('配置已指向 ' + (hubInfo.effectiveNow || '') + ',重启 view 后生效');\n $('#ovHubMeta').textContent = metaParts.join(' · ');\n $('#hubConfigClearBtn').hidden = !hubInfo.configured;\n }\n\n $('#hubConfigBtn').onclick = function () {\n var current = (state.overview && state.overview.hubDir) || '';\n var body = '<p class=\"label\">写入 config.json 持久覆盖(优先级低于 --hub 参数 / YOTTA_SKILLS_HUB)。不会自动迁移旧 Hub 内容;迁移用 CLI:yotta-skills hub config set --hub &lt;path&gt; --move。</p>' +\n '<label class=\"label\">新的 Hub 目录</label>' +\n '<input id=\"hubNewPath\" placeholder=\"D:\\\\my-hub\" value=\"\">' +\n '<div class=\"note mono\">当前: ' + esc(current) + '</div>';\n openModal('设置 Hub 位置', body, {\n okText: '写入配置',\n onOk: async function () {\n var value = ($('#hubNewPath').value || '').trim();\n if (!value) { toast('先填写新的 Hub 目录'); return; }\n var r = await api('/api/hub/config', { body: { hub: value, confirm: 'hub-config' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已写入配置;重启 view 后生效');\n loadOverview();\n },\n });\n };\n $('#hubConfigClearBtn').onclick = function () {\n confirmModal('清除 Hub 位置覆盖?', '<p class=\"label\">将删除 config.json 中的 Hub 覆盖,恢复 --hub &gt; 环境变量 &gt; 默认位置的解析。不会移动任何文件。</p>', '清除覆盖', true, async function () {\n var r = await api('/api/hub/config', { body: { clear: true, confirm: 'hub-config' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清除覆盖;重启 view 后生效');\n loadOverview();\n });\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";
66
69
  // @generated view-html:end
67
70
 
68
71
  function loadManifest() {
@@ -165,6 +168,8 @@ function parseArgs(argv) {
165
168
  hub: null, all: false, includeDiscovered: false, inPlace: false, allowUnverified: false,
166
169
  from: null, include: [], as: null, skillsCli: null,
167
170
  scan: false, apply: false,
171
+ clear: false,
172
+ move: false,
168
173
  port: null,
169
174
  };
170
175
  const positionals = [];
@@ -219,6 +224,8 @@ function parseArgs(argv) {
219
224
  else if (a === '--state') opts.state = take('--state').toLowerCase();
220
225
  else if (a === '--label') opts.label = take('--label');
221
226
  else if (a === '--id') opts.id = take('--id').toLowerCase();
227
+ else if (a === '--clear') opts.clear = true;
228
+ else if (a === '--move') opts.move = true;
222
229
  else if (a === '--purge') opts.purge = true;
223
230
  else if (a === '--all') opts.all = true;
224
231
  else if (a === '--include-discovered') opts.includeDiscovered = true;
@@ -305,6 +312,15 @@ function parseArgs(argv) {
305
312
  if (opts.domain && !domainValues().includes(opts.domain)) {
306
313
  die('未知 domain: ' + opts.domain + '。可用: ' + domainValues().join(', '), 2, 'domain 对齐家族索引 9 类;用 yotta-skills --list 查看技能清单。');
307
314
  }
315
+ // 0.29.2 U4:--hub 只在 hub / view / where 生效;其它命令静默忽略是误用坑,fail-closed。
316
+ if (opts.hub && !(opts.command === 'hub' || opts.command === 'view' || opts.command === 'where')) {
317
+ die('--hub 只能与 hub / view / where 一起使用', 2,
318
+ '示例:yotta-skills hub status --hub "D:\\my-hub"。装到宿主目录请用 --dir <path>。');
319
+ }
320
+ if (opts.move && !(opts.command === 'hub' && opts.hubAction === 'config' && opts.skills[0] === 'set')) {
321
+ die('--move 只能与 hub config set 一起使用', 2,
322
+ '示例:yotta-skills hub config set --hub "D:\\my-hub" --move。');
323
+ }
308
324
  return opts;
309
325
  }
310
326
 
@@ -389,7 +405,7 @@ function readInstalledVersion(dir) {
389
405
  const f = path.join(dir, 'SKILL.md');
390
406
  try {
391
407
  const text = fs.readFileSync(f, 'utf8');
392
- const m = text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
408
+ const m = text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)/m);
393
409
  return m ? m[1] : null;
394
410
  } catch (_) { return null; }
395
411
  }
@@ -430,7 +446,7 @@ function readInstalledMeta(dir) {
430
446
  try {
431
447
  var text = fs.readFileSync(f, 'utf8');
432
448
  var name = (text.match(/^name:\s*(.+)$/m) || [])[1];
433
- var version = (text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m) || [])[1];
449
+ var version = (text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)/m) || [])[1];
434
450
  return { name: name ? name.trim() : null, version: version || null };
435
451
  } catch (e) { return { name: null, version: null }; }
436
452
  }
@@ -480,6 +496,7 @@ async function runUpdateCheck(opts, dest) {
480
496
  updatable: cachedRows.filter(function (x) { return x.hasUpdate; }),
481
497
  updates: (target && target.last_result && target.last_result.updates) || 0,
482
498
  latest: (target && target.last_result && target.last_result.latest) || 0,
499
+ localAhead: (target && target.last_result && target.last_result.localAhead) || 0,
483
500
  failed: 0,
484
501
  nonFamily: (target && target.last_result && target.last_result.nonFamily) || 0,
485
502
  cache: target,
@@ -494,13 +511,14 @@ async function runUpdateCheck(opts, dest) {
494
511
  rows: cachedRows,
495
512
  updates: cachedPayload.updates,
496
513
  latest: cachedPayload.latest,
514
+ localAhead: cachedPayload.localAhead,
497
515
  failed: 0,
498
516
  nonFamily: cachedPayload.nonFamily,
499
517
  };
500
518
  }
501
519
  var installed = scanInstalledSlugs(dest);
502
520
  if (installed.length === 0) {
503
- var emptyResult = { updates: 0, latest: 0, failed: 0, nonFamily: 0, rows: [], errors: [] };
521
+ var emptyResult = { updates: 0, latest: 0, localAhead: 0, failed: 0, nonFamily: 0, rows: [], errors: [] };
504
522
  var emptyCacheRecord = null;
505
523
  try {
506
524
  emptyCacheRecord = updateCheckLib.recordCheck(os.homedir(), dest, { result: emptyResult, error: null });
@@ -517,6 +535,7 @@ async function runUpdateCheck(opts, dest) {
517
535
  rows: [],
518
536
  updates: 0,
519
537
  latest: 0,
538
+ localAhead: 0,
520
539
  failed: 0,
521
540
  nonFamily: 0,
522
541
  cache: emptyCacheRecord,
@@ -524,7 +543,7 @@ async function runUpdateCheck(opts, dest) {
524
543
  }
525
544
  var rows = [];
526
545
  var failures = [];
527
- var updates = 0, latest = 0, failed = 0, nonFamily = 0;
546
+ var updates = 0, latest = 0, localAhead = 0, failed = 0, nonFamily = 0;
528
547
  for (var i = 0; i < installed.length; i++) {
529
548
  var it = installed[i];
530
549
  var fam = familySkillFor(it.slug);
@@ -542,22 +561,37 @@ async function runUpdateCheck(opts, dest) {
542
561
  }
543
562
  var current = res.version;
544
563
  var cur = it.version || null;
545
- var isUp = (cur === current);
546
- if (isUp) {
547
- latest++;
548
- say(' ✔ ' + it.slug.padEnd(22) + '已最新(本地 v' + cur + ')');
549
- } else {
564
+ var relation = versionRelation(cur, current);
565
+ if (relation === 'upgrade') {
550
566
  updates++;
551
567
  say(' ➤ ' + it.slug.padEnd(22) + '有更新:本地 v' + (cur || '未知') + ' -> 最新 v' + current);
552
568
  if (fam.version && fam.version !== current) {
553
569
  say(' ⚠ 清单 skills.json 记为 v' + fam.version + ',与 npm 最新 v' + current + ' 不一致(需同步清单)');
554
570
  }
555
- }
556
- rows.push({ slug: it.slug, installed: cur, latest: current, pkg: fam.pkg, hasUpdate: !isUp, family: fam });
571
+ } else if (relation === 'equal') {
572
+ latest++;
573
+ say(' ✔ ' + it.slug.padEnd(22) + '已最新(本地 v' + cur + ')');
574
+ } else if (relation === 'local-ahead') {
575
+ localAhead++;
576
+ say(' ⏸ ' + it.slug.padEnd(22) + '本地领先:本地 v' + cur + ' > 最新 v' + current + '(保留,不计入更新)');
577
+ } else {
578
+ say(' ? ' + it.slug.padEnd(22) + '无法比较版本(本地 v' + (cur || '未知') + ' / 最新 v' + current + '),保留');
579
+ }
580
+ rows.push({
581
+ slug: it.slug,
582
+ installed: cur,
583
+ latest: current,
584
+ pkg: fam.pkg,
585
+ hasUpdate: relation === 'upgrade',
586
+ localAhead: relation === 'local-ahead',
587
+ relation: relation,
588
+ family: fam,
589
+ });
557
590
  }
558
591
  var result = {
559
592
  updates: updates,
560
593
  latest: latest,
594
+ localAhead: localAhead,
561
595
  failed: failed,
562
596
  nonFamily: nonFamily,
563
597
  rows: rows,
@@ -587,15 +621,16 @@ async function runUpdateCheck(opts, dest) {
587
621
  updatable: updatable,
588
622
  updates: updates,
589
623
  latest: latest,
624
+ localAhead: localAhead,
590
625
  failed: failed,
591
626
  nonFamily: nonFamily,
592
627
  cache: cacheRecord,
593
628
  error: lastError,
594
629
  }, null, 2));
595
630
  }
596
- return { code: 0, scheduled: true, due: true, checked: true, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
631
+ return { code: 0, scheduled: true, due: true, checked: true, rows: rows, updates: updates, latest: latest, localAhead: localAhead, failed: failed, nonFamily: nonFamily };
597
632
  }
598
- say('汇总: 有更新 ' + updates + ' / 已最新 ' + latest + ' / 检查失败 ' + failed + ' / 非家族跳过 ' + nonFamily);
633
+ say('汇总: 有更新 ' + updates + ' / 本地领先 ' + localAhead + ' / 已最新 ' + latest + ' / 检查失败 ' + failed + ' / 非家族跳过 ' + nonFamily);
599
634
  for (var lineIndex = 0; lineIndex < lines.length; lineIndex++) out(lines[lineIndex]);
600
635
  if (opts.json) {
601
636
  out(JSON.stringify({
@@ -603,13 +638,14 @@ async function runUpdateCheck(opts, dest) {
603
638
  updatable: updatable,
604
639
  updates: updates,
605
640
  latest: latest,
641
+ localAhead: localAhead,
606
642
  failed: failed,
607
643
  nonFamily: nonFamily,
608
644
  cache: cacheRecord,
609
645
  error: lastError,
610
646
  }, null, 2));
611
647
  }
612
- return { code: code, scheduled: false, due: true, checked: true, rows: rows, updates: updates, latest: latest, failed: failed, nonFamily: nonFamily };
648
+ return { code: code, scheduled: false, due: true, checked: true, rows: rows, updates: updates, latest: latest, localAhead: localAhead, failed: failed, nonFamily: nonFamily };
613
649
  }
614
650
 
615
651
  async function runUpdateAuto(opts, dest) {
@@ -620,7 +656,11 @@ async function runUpdateAuto(opts, dest) {
620
656
  return { code: 1 };
621
657
  }
622
658
  if (r.updates === 0) {
623
- out('全部已最新,无需更新。');
659
+ if (r.localAhead > 0) {
660
+ out('本地领先 ' + r.localAhead + ' 个(保留不降级);其余已最新,无需更新。');
661
+ } else {
662
+ out('全部已最新,无需更新。');
663
+ }
624
664
  return { code: 0 };
625
665
  }
626
666
  out('');
@@ -632,7 +672,7 @@ async function runUpdateAuto(opts, dest) {
632
672
  var res = installOne(
633
673
  Object.assign({}, row.family, { version: row.latest }),
634
674
  dest,
635
- Object.assign({}, opts, { force: true, pin: true, skipScan: false }),
675
+ Object.assign({}, opts, { pin: true, skipScan: false }),
636
676
  );
637
677
  if (res.status === 'ok') ok++;
638
678
  else if (res.status === 'skip') ok++;
@@ -643,7 +683,7 @@ async function runUpdateAuto(opts, dest) {
643
683
  }
644
684
  }
645
685
  out('');
646
- out('自动更新汇总: 成功 ' + ok + ' / 失败 ' + failed2);
686
+ out('自动更新汇总: 成功 ' + ok + ' / 失败 ' + failed2 + (r.localAhead > 0 ? ' / 本地领先保留 ' + r.localAhead : ''));
647
687
  if (failed2 === 0) maybeAutoReindex(opts, dest);
648
688
  return { code: failed2 > 0 ? autoExitCode : 0 };
649
689
  }
@@ -1004,11 +1044,29 @@ function runUpdate(opts, dest) {
1004
1044
  const results = [];
1005
1045
  let failed = 0;
1006
1046
  let exitCode = 0;
1047
+ const pinExact = opts.pin !== false;
1007
1048
  for (const s of skills) {
1008
1049
  const existing = readInstalledVersion(path.join(dest, s.slug));
1009
- if (existing === s.version) {
1050
+ const relation = versionRelation(existing, s.version);
1051
+ if (!opts.force && pinExact && relation === 'equal') {
1010
1052
  if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (已是最新,v' + existing + ')');
1011
- results.push({ skill: s, status: 'skip', version: existing });
1053
+ results.push({ skill: s, status: 'skip', version: existing, note: '已是最新' });
1054
+ continue;
1055
+ }
1056
+ if (!opts.force && pinExact && relation === 'local-ahead') {
1057
+ if (!quiet) {
1058
+ out(' - ' + s.slug.padEnd(22) + s.name + ' (本地领先 v' + existing + ' > 目标 v' + s.version +
1059
+ ',保留不降级;如需强制覆盖用 --force)');
1060
+ }
1061
+ results.push({ skill: s, status: 'skip', version: existing, note: '本地领先,保留不降级' });
1062
+ continue;
1063
+ }
1064
+ if (!opts.force && pinExact && relation === 'unknown') {
1065
+ if (!quiet) {
1066
+ out(' - ' + s.slug.padEnd(22) + s.name + ' (无法比较版本(本地 v' + existing + ' / 目标 v' + s.version +
1067
+ '),保留;如需强制覆盖用 --force)');
1068
+ }
1069
+ results.push({ skill: s, status: 'skip', version: existing, note: '无法比较版本,保留' });
1012
1070
  continue;
1013
1071
  }
1014
1072
  const r = installOne(s, dest, opts);
@@ -1917,6 +1975,10 @@ function hubScopeLine(opts, discovery) {
1917
1975
  function hubTargetDirs(opts, discovery) {
1918
1976
  const targets = [];
1919
1977
  const seen = new Set();
1978
+ // 0.29.1 U2:不接管名单 / 目录覆盖(与发现层同源 hosts.json)。
1979
+ const registry = hostsRegistryLib.readHostsRegistry({ homeDir: os.homedir(), env: process.env });
1980
+ const excludedEntry = (input) => hostsRegistryLib.excludedEntryFor(registry, input);
1981
+ const cleanupOnly = opts.hubAction === 'unlink' || opts.hubAction === 'remove';
1920
1982
  const add = (dir, agentId, label, meta) => {
1921
1983
  if (!dir) return;
1922
1984
  const resolved = path.resolve(dir);
@@ -1937,7 +1999,15 @@ function hubTargetDirs(opts, discovery) {
1937
1999
  if (opts.dir) {
1938
2000
  // Bridge dirs are never link targets, but `unlink --dir` stays available:
1939
2001
  // it is the explicit cleanup path for historical out-of-scope links.
1940
- if (opts.hubAction !== 'unlink' && opts.hubAction !== 'remove' &&
2002
+ if (!cleanupOnly && agentDirsLib.isYottaCodeDir(opts.dir)) {
2003
+ die('YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),不纳入元阁接管', 2,
2004
+ 'YottaCode 目录由它自己管理,元阁不链接其目录。');
2005
+ }
2006
+ if (!cleanupOnly && excludedEntry({ dir: opts.dir })) {
2007
+ die('该目录在「不接管」名单:' + path.resolve(opts.dir), 2,
2008
+ '恢复接管:yotta-skills hub hosts include "' + path.resolve(opts.dir) + '";再重新链接。');
2009
+ }
2010
+ if (!cleanupOnly &&
1941
2011
  agentDirsLib.isBridgeOnlyDir(opts.dir, { homeDir: os.homedir(), env: process.env })) {
1942
2012
  die('该目录是锁 / 数据桥接目录(XDG_STATE_HOME/skills 或 XDG_DATA_HOME/skills),永不作为链接目标', 2,
1943
2013
  '如需链接真实宿主目录,请改用 --agent <id> 或其它 --dir 路径。');
@@ -1951,7 +2021,19 @@ function hubTargetDirs(opts, discovery) {
1951
2021
  die('未收录智能体: ' + opts.agent + '。可用: ' + Object.keys(AGENT_DIRS).join(', ') + ';或改用 --dir <路径>。',
1952
2022
  2, '先运行 yotta-skills hub hosts 查看本机实际技能目录。');
1953
2023
  }
1954
- add(resolveUserDir(info.dirs[0]), opts.agent, info.label, { explicit: true, verified: info.verified !== false });
2024
+ if (!cleanupOnly && (info.neverLink || agentDirsLib.isYottaCodeDir(resolveUserDir(info.dirs[0])))) {
2025
+ die('YottaCode 自带三层技能管理(skill-inventory / skill / user-skills),不纳入元阁接管', 2,
2026
+ 'YottaCode 目录由它自己管理,元阁不链接其目录。');
2027
+ }
2028
+ if (!cleanupOnly && excludedEntry({ agentId: opts.agent })) {
2029
+ die('该智能体在「不接管」名单:' + opts.agent, 2,
2030
+ '恢复接管:yotta-skills hub hosts include ' + opts.agent + ';再重新链接。');
2031
+ }
2032
+ const override = (registry.overrides || [])
2033
+ .find((item) => String(item.agentId).toLowerCase() === opts.agent);
2034
+ const dir = override ? override.dir : resolveUserDir(info.dirs[0]);
2035
+ add(dir, opts.agent, override ? (override.label || info.label) : info.label,
2036
+ { explicit: true, verified: true });
1955
2037
  return targets;
1956
2038
  }
1957
2039
  if (!opts.all) {
@@ -1959,7 +2041,11 @@ function hubTargetDirs(opts, discovery) {
1959
2041
  '示例:yotta-skills hub link --all;或 yotta-skills hub link --agent codex。');
1960
2042
  }
1961
2043
 
1962
- for (const item of universalSkillDirs()) add(item.dir, item.agentId, item.label, { verified: true });
2044
+ for (const item of universalSkillDirs()) {
2045
+ if (agentDirsLib.isYottaCodeDir(item.dir)) continue;
2046
+ if (excludedEntry({ dir: item.dir, agentId: item.agentId })) continue;
2047
+ add(item.dir, item.agentId, item.label, { verified: true });
2048
+ }
1963
2049
  for (const host of discovery.hosts) {
1964
2050
  if (!host.exists) continue;
1965
2051
  if (host.bridgeOnly) continue;
@@ -2015,8 +2101,14 @@ function hostStateLabel(host) {
2015
2101
 
2016
2102
  function hostScopeLabel(host) {
2017
2103
  if (host.bridgeOnly) return '[桥接·不链接]';
2018
- const discovered = host.verified ? '' : '·自动发现';
2019
- return '[' + hostStateLabel(host) + discovered + ']';
2104
+ let scope = '';
2105
+ if (!host.verified) {
2106
+ // 0.29.1 U2:未核实映射 ≠ 自动发现;「自动发现」只用于文件系统扫描结果。
2107
+ scope = (host.detection === 'discovered' || host.detection === 'workspace')
2108
+ ? '·自动发现'
2109
+ : '·映射未核实';
2110
+ }
2111
+ return '[' + hostStateLabel(host) + scope + ']';
2020
2112
  }
2021
2113
 
2022
2114
  function hostStateCounts(hosts) {
@@ -2046,8 +2138,8 @@ function hubHostLines(discovery) {
2046
2138
  lines.push(' △ ' + String(host.label).padEnd(28) + String(host.dir).padEnd(64) +
2047
2139
  ' 0 个技能 ' + hostScopeLabel(host));
2048
2140
  }
2049
- const noSkillDir = discovery.installed
2050
- .filter((item) => !item.hasSkillsDir && !shown.has(String(item.label).toLowerCase()));
2141
+ // 0.29.1 U2:CLI / 面板共用单一真源(未配对且无技能目录的标记)。
2142
+ const noSkillDir = agentDiscoveryLib.unpairedMarkers(discovery);
2051
2143
  for (const item of noSkillDir) {
2052
2144
  lines.push(' △ ' + String(item.label).padEnd(28) + '已安装,未发现技能目录 [不适用]');
2053
2145
  }
@@ -2067,6 +2159,7 @@ function printHubHosts(discovery, json) {
2067
2159
  ' / 残留 ' + counts.orphan + ' / 桥接 ' + bridgeCount + ' / 已忽略 ' + counts.ignored +
2068
2160
  ')/ 仅标记 ' + counts['marker-only'] + ' 个 / 未创建 ' + counts.missing + ' 个');
2069
2161
  out('默认链接范围 = 已核实目录(含残留,可清理);--include-discovered 显式纳入自动发现目录;锁 / 数据桥接目录永不链接。');
2162
+ out('不接管名单(发现 / 显示 / 链接全部跳过):yotta-skills hub hosts exclude <agentId|目录>;恢复用 include。');
2070
2163
  out('残留清理:yotta-skills hub hosts remove <目录> --purge(默认预览,--yes 执行)。');
2071
2164
  for (const line of view.lines) out(line);
2072
2165
  if (view.missing.length > 0) {
@@ -2212,9 +2305,35 @@ function printHubDoctor(payload, json) {
2212
2305
  if (!payload.ok) out('Hub doctor 未通过:请按上面的修复建议处理后重试。');
2213
2306
  }
2214
2307
 
2308
+ /** 0.29.1 U3:hub install / update 成功、失败各写一条 Hub 审计(skip / dry-run 不写;best-effort)。 */
2309
+ function appendHubInstallAudit(hubDir, action, results) {
2310
+ for (const r of results || []) {
2311
+ if (!r || r.status === 'skip' || r.status === 'planned') continue;
2312
+ const entry = {
2313
+ event: action === 'install' ? 'install' : 'update',
2314
+ slug: r.skill && r.skill.slug ? r.skill.slug : null,
2315
+ version: r.version || null,
2316
+ source: 'npm',
2317
+ via: 'hub',
2318
+ ok: r.status === 'ok',
2319
+ };
2320
+ if (r.snapshot) entry.snapshot = r.snapshot;
2321
+ if (r.gate && r.gate.verdict) entry.verdict = r.gate.verdict;
2322
+ if (r.status !== 'ok') entry.error = r.note || 'unknown';
2323
+ try {
2324
+ hubLib.appendAudit(hubDir, entry);
2325
+ } catch (_) { /* best-effort:审计失败不阻断安装 / 更新主流程 */ }
2326
+ }
2327
+ }
2328
+
2215
2329
  function runHub(opts) {
2216
2330
  const hubDir = hubLib.resolveHubDir(opts);
2217
2331
  const action = opts.hubAction || 'status';
2332
+ // 0.29.2 U4:config 不依赖宿主发现,提前派发(get/set/clear 无需扫描宿主目录)。
2333
+ if (action === 'config') {
2334
+ runHubConfig(opts);
2335
+ return;
2336
+ }
2218
2337
  const discovery = agentDiscoveryLib.discoverHosts({ homeDir: os.homedir(), env: process.env });
2219
2338
 
2220
2339
  if (action === 'hosts' || action === 'discover') {
@@ -2223,9 +2342,12 @@ function runHub(opts) {
2223
2342
  if (sub === 'remove') { runHostsRemove(opts); return; }
2224
2343
  if (sub === 'list') { runHostsList(opts); return; }
2225
2344
  if (sub === 'mark') { runHostsMark(opts); return; }
2345
+ if (sub === 'exclude') { runHostsExclude(opts); return; }
2346
+ if (sub === 'include') { runHostsInclude(opts); return; }
2347
+ if (sub === 'set') { runHostsSet(opts); return; }
2226
2348
  if (sub) {
2227
2349
  die('未知 hub hosts 子命令: ' + sub, 2,
2228
- '支持 add / remove / list / mark;无子命令 = 只读发现。');
2350
+ '支持 add / remove / list / mark / exclude / include / set;无子命令 = 只读发现。');
2229
2351
  }
2230
2352
  const view = opts.state
2231
2353
  ? { ...discovery, hosts: discovery.hosts.filter((host) => host.state === opts.state) }
@@ -2285,6 +2407,7 @@ function runHub(opts) {
2285
2407
  out('非元技能 ' + external + ' 个不参与更新(来源无统一安装源,用户自行处理)。');
2286
2408
  }
2287
2409
  out('Hub 台账已更新: ' + count + ' 个技能;标准 ' + hubLib.STANDARD_ID);
2410
+ appendHubInstallAudit(hubDir, action, result.results);
2288
2411
  if (result.failed > 0) process.exitCode = result.exitCode || 1;
2289
2412
  return;
2290
2413
  }
@@ -2332,10 +2455,15 @@ function runHub(opts) {
2332
2455
  manifest: MANIFEST,
2333
2456
  homeDir: os.homedir(),
2334
2457
  env: process.env,
2335
- scan: (skillDir) => hubScanSkill(hubDir, discovery, opts, skillDir),
2458
+ scan: (skillDir) => hubScanSkill(hubDir, discovery, opts, skillDir, { slug: opts.skills[0] }),
2336
2459
  });
2337
2460
  if (opts.json) out(JSON.stringify(result, null, 2));
2338
- else if (result.ok) out('已刷新: ' + result.slug + '(来源 ' + result.source + ')');
2461
+ else if (result.ok) {
2462
+ out('已刷新: ' + result.slug + '(来源 ' + result.source + ')');
2463
+ if (result.scanPolicy && result.scanPolicy.applied && result.scanPolicy.excluded > 0) {
2464
+ out(' ↳ scanPolicy 复核:豁免 ' + result.scanPolicy.excluded + ' 条已审查发现 → ' + result.verdict);
2465
+ }
2466
+ }
2339
2467
  else out('刷新失败: ' + result.error);
2340
2468
  if (!result.ok) process.exitCode = 1;
2341
2469
  return;
@@ -2475,7 +2603,7 @@ function runHub(opts) {
2475
2603
  }
2476
2604
 
2477
2605
  die('未知 hub 子命令: ' + action, 2,
2478
- '支持 install / update / adopt / refresh / link / unlink / remove / status / hosts / doctor。');
2606
+ '支持 install / update / adopt / refresh / link / unlink / remove / status / hosts / config / doctor。');
2479
2607
  }
2480
2608
 
2481
2609
  // ── hub hosts add / remove / list / mark(0.29.0 F2/F3 注册表与手动标记) ─────
@@ -2491,6 +2619,176 @@ function auditHostEvent(opts, entry) {
2491
2619
  } catch (_) { /* 审计失败不阻断注册 / 清理主流程 */ }
2492
2620
  }
2493
2621
 
2622
+ function auditHubConfigEvent(hubDir, entry) {
2623
+ if (!hubDir || !fs.existsSync(hubDir)) return;
2624
+ try {
2625
+ hubLib.appendAudit(hubDir, entry);
2626
+ } catch (_) { /* 审计失败不阻断配置主流程 */ }
2627
+ }
2628
+
2629
+ /** 0.29.2 U4:hub config get / set / clear(Hub 位置持久化,config.json)。 */
2630
+ function runHubConfig(opts) {
2631
+ const sub = opts.skills[0] || 'get';
2632
+ const configOpts = { homeDir: os.homedir(), env: process.env };
2633
+
2634
+ if (sub === 'get') {
2635
+ const resolved = hubLib.resolveHub({ ...configOpts, hub: opts.hub });
2636
+ const payload = {
2637
+ action: 'config.get',
2638
+ hub: resolved.dir,
2639
+ source: resolved.source,
2640
+ configured: resolved.configured,
2641
+ configFile: resolved.configFile,
2642
+ defaultDir: resolved.defaultDir,
2643
+ };
2644
+ if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
2645
+ out('yotta-skills(元阁)v' + VERSION + ' —— Hub 配置');
2646
+ out('生效 Hub: ' + payload.hub + '(来源: ' + skillsConfigLib.sourceLabel(payload.source) + ')');
2647
+ out('配置覆盖: ' + (payload.configured || '无'));
2648
+ out('配置文件: ' + payload.configFile);
2649
+ out('默认位置: ' + payload.defaultDir);
2650
+ return;
2651
+ }
2652
+
2653
+ if (sub === 'set') {
2654
+ if (!opts.hub) {
2655
+ die('hub config set 需要 --hub <path>', 2,
2656
+ '示例:yotta-skills hub config set --hub "D:\\my-hub"。');
2657
+ }
2658
+ const current = hubLib.resolveHub(configOpts);
2659
+ const guard = skillsConfigLib.validateHubPath(opts.hub, configOpts);
2660
+ if (!guard.ok) die(guard.error, 2, '校验路径后重试;迁移(--move)仅在旧 Hub 有内容时需要。');
2661
+ const target = guard.dir;
2662
+ if (opts.move && path.resolve(target) === path.resolve(current.dir)) {
2663
+ die('新旧 Hub 路径相同,无需 --move', 2, '直接设置其它位置:hub config set --hub <path>。');
2664
+ }
2665
+
2666
+ let moveReport = null;
2667
+ if (opts.move) {
2668
+ const oldHasContent = fs.existsSync(current.dir) && fs.readdirSync(current.dir).length > 0;
2669
+ if (!oldHasContent) {
2670
+ const setResult = skillsConfigLib.setHub(configOpts, { hub: target });
2671
+ if (!setResult.ok) die(setResult.error, 2, '检查路径后重试。');
2672
+ moveReport = {
2673
+ moved: false,
2674
+ reason: '旧 Hub 不存在或为空(' + current.dir + '),仅切换指针',
2675
+ trashedTo: null,
2676
+ relink: [],
2677
+ };
2678
+ } else {
2679
+ const staged = hubMigrateLib.stageHubCopy({ from: current.dir, to: target });
2680
+ if (!staged.ok) {
2681
+ die(staged.error, 2, '迁移 fail-closed:配置未切换、旧 Hub 未动;可改用不带 --move 的 set。');
2682
+ }
2683
+ const setResult = skillsConfigLib.setHub(configOpts, { hub: target });
2684
+ if (!setResult.ok) {
2685
+ hubMigrateLib.discardStagedCopy({ to: target, existed: staged.existed });
2686
+ die('配置写入失败:' + setResult.error + '(已清理暂存副本,旧 Hub 未动)', 1);
2687
+ }
2688
+ const discovery = agentDiscoveryLib.discoverHosts({ homeDir: os.homedir(), env: process.env });
2689
+ const targets = hubTargetDirs({ all: true, hubAction: 'config' }, discovery)
2690
+ .filter((item) => fs.existsSync(item.dir));
2691
+ const finish = hubMigrateLib.relinkAndRetireOldHub({
2692
+ from: current.dir,
2693
+ to: target,
2694
+ targets,
2695
+ manifest: MANIFEST,
2696
+ homeDir: os.homedir(),
2697
+ env: process.env,
2698
+ });
2699
+ moveReport = {
2700
+ moved: true,
2701
+ verifiedSkills: staged.verifiedSkills,
2702
+ relink: finish.relink,
2703
+ incomplete: finish.incomplete,
2704
+ trashedTo: finish.trashedTo,
2705
+ trashError: finish.trashError,
2706
+ oldHubKept: (finish.incomplete > 0 || finish.trashError) ? current.dir : null,
2707
+ };
2708
+ }
2709
+ } else {
2710
+ const setResult = skillsConfigLib.setHub(configOpts, { hub: target });
2711
+ if (!setResult.ok) die(setResult.error, 2, '检查路径后重试。');
2712
+ moveReport = {
2713
+ moved: false,
2714
+ reason: '未指定 --move:仅切换指针(旧 Hub 内容保留)。填充新 Hub:hub install;重链:hub link --all --force',
2715
+ trashedTo: null,
2716
+ relink: [],
2717
+ };
2718
+ }
2719
+
2720
+ const resolved = hubLib.resolveHub(configOpts);
2721
+ const payload = {
2722
+ action: 'config.set',
2723
+ hub: resolved.dir,
2724
+ source: resolved.source,
2725
+ previous: current.dir,
2726
+ previousSource: current.source,
2727
+ configFile: resolved.configFile,
2728
+ move: moveReport,
2729
+ };
2730
+ auditHubConfigEvent(fs.existsSync(target) ? target : current.dir, {
2731
+ event: 'config.set',
2732
+ hub: target,
2733
+ previousHub: current.dir,
2734
+ source: resolved.source,
2735
+ moved: Boolean(moveReport && moveReport.moved),
2736
+ via: 'cli',
2737
+ });
2738
+ if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
2739
+ out('yotta-skills(元阁)v' + VERSION + ' —— Hub 配置');
2740
+ out('✔ 已设置 Hub: ' + payload.hub + '(来源: ' + skillsConfigLib.sourceLabel(payload.source) + ')');
2741
+ out(' 配置文件: ' + payload.configFile);
2742
+ if (moveReport && moveReport.moved) {
2743
+ out(' 迁移: 复制校验 ' + moveReport.verifiedSkills + ' 个技能;重链 ' + moveReport.relink.length + ' 个宿主目录');
2744
+ if (moveReport.incomplete > 0) {
2745
+ out(' ⚠ 重链未全部完成(' + moveReport.incomplete + ' 项);旧 Hub 保留在 ' + moveReport.oldHubKept +
2746
+ ',可重跑:yotta-skills hub link --all --force');
2747
+ } else if (moveReport.trashedTo) {
2748
+ out(' 旧 Hub 已移入回收站: ' + moveReport.trashedTo + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
2749
+ } else if (moveReport.trashError) {
2750
+ out(' ⚠ 旧 Hub 移入回收站失败(已保留原位): ' + moveReport.trashError);
2751
+ }
2752
+ } else if (moveReport) {
2753
+ out(' ' + moveReport.reason);
2754
+ }
2755
+ out(' 提示: 已在运行的 view 面板需重启后生效。');
2756
+ return;
2757
+ }
2758
+
2759
+ if (sub === 'clear') {
2760
+ const current = hubLib.resolveHub(configOpts);
2761
+ const result = skillsConfigLib.clearHub(configOpts);
2762
+ if (!result.ok) {
2763
+ die(result.error + '(当前来源: ' + skillsConfigLib.sourceLabel(current.source) + ')', 2,
2764
+ '当前生效位置: ' + current.dir + ';如需改位置用 hub config set --hub <path>。');
2765
+ }
2766
+ const resolved = hubLib.resolveHub(configOpts);
2767
+ auditHubConfigEvent(fs.existsSync(current.dir) ? current.dir : resolved.dir, {
2768
+ event: 'config.clear',
2769
+ removed: result.removed,
2770
+ hub: resolved.dir,
2771
+ via: 'cli',
2772
+ });
2773
+ const payload = {
2774
+ action: 'config.clear',
2775
+ removed: result.removed,
2776
+ hub: resolved.dir,
2777
+ source: resolved.source,
2778
+ configFile: result.configFile,
2779
+ };
2780
+ if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
2781
+ out('yotta-skills(元阁)v' + VERSION + ' —— Hub 配置');
2782
+ out('✔ 已清除配置覆盖: ' + payload.removed);
2783
+ out(' 生效 Hub: ' + payload.hub + '(来源: ' + skillsConfigLib.sourceLabel(payload.source) + ')');
2784
+ out(' 提示: 已在运行的 view 面板需重启后生效。');
2785
+ return;
2786
+ }
2787
+
2788
+ die('未知 hub config 子命令: ' + sub, 2,
2789
+ '支持 get / set / clear;示例:yotta-skills hub config set --hub "D:\\my-hub" --move。');
2790
+ }
2791
+
2494
2792
  /** hub hosts 子命令的原始位置参数(保留路径大小写;slug 版本已小写化)。 */
2495
2793
  function hostsSubcommandArg(opts, index) {
2496
2794
  if (opts.rawRest && opts.rawRest.length > index) return opts.rawRest[index];
@@ -2536,12 +2834,14 @@ function runHostsList(opts) {
2536
2834
  action: 'hosts.list',
2537
2835
  registryPath: hostsRegistryLib.hostsRegistryPath(registryOpts),
2538
2836
  hosts: registry.hosts,
2837
+ excluded: registry.excluded,
2838
+ overrides: registry.overrides,
2539
2839
  }, null, 2));
2540
2840
  return;
2541
2841
  }
2542
2842
  out('yotta-skills(元阁)v' + VERSION + ' —— 自定义宿主注册表');
2543
2843
  out('注册表: ' + hostsRegistryLib.hostsRegistryPath(registryOpts));
2544
- if (registry.hosts.length === 0) {
2844
+ if (registry.hosts.length === 0 && (registry.excluded || []).length === 0 && (registry.overrides || []).length === 0) {
2545
2845
  out('(空;用 yotta-skills hub hosts add <目录> 注册)');
2546
2846
  return;
2547
2847
  }
@@ -2549,6 +2849,21 @@ function runHostsList(opts) {
2549
2849
  out(' ' + String(item.label).padEnd(28) + item.dir +
2550
2850
  (item.manualState ? ' [手动: ' + item.manualState + ']' : ''));
2551
2851
  }
2852
+ if ((registry.excluded || []).length > 0) {
2853
+ out('');
2854
+ out('不接管名单 ' + registry.excluded.length + ' 个(发现 / 显示 / 链接全部跳过):');
2855
+ for (const item of registry.excluded) {
2856
+ out(' ⊘ ' + String(item.label || item.value).padEnd(28) + item.value);
2857
+ }
2858
+ }
2859
+ if ((registry.overrides || []).length > 0) {
2860
+ out('');
2861
+ out('目录覆盖 ' + registry.overrides.length + ' 个:');
2862
+ for (const item of registry.overrides) {
2863
+ out(' ↳ ' + String(item.label || item.agentId).padEnd(28) + item.dir +
2864
+ '(原: ' + (item.previousDirs || []).join(' / ') + ')');
2865
+ }
2866
+ }
2552
2867
  }
2553
2868
 
2554
2869
  function runHostsRemove(opts) {
@@ -2603,6 +2918,93 @@ function runHostsMark(opts) {
2603
2918
  out('✔ 已手动标记: ' + result.entry.dir + ' -> ' + state);
2604
2919
  }
2605
2920
 
2921
+ /** 0.29.1 U2:不接管开关(发现 / 显示 / 链接三层全部跳过,--include-discovered 也不纳入)。 */
2922
+ function runHostsExclude(opts) {
2923
+ const target = hostsSubcommandArg(opts, 2);
2924
+ if (!target) {
2925
+ die('hub hosts exclude 需要 <agentId|目录>', 2,
2926
+ '示例:yotta-skills hub hosts exclude box;或 hub hosts exclude "D:\\my-agent\\skills"。');
2927
+ }
2928
+ const registryOpts = hostRegistryOptions(opts);
2929
+ const result = hostsRegistryLib.excludeHost(registryOpts, { target });
2930
+ if (!result.ok) {
2931
+ die(result.error, 2, '目标可以是已收录 agentId,或技能目录(非 Hub / 非锁桥接 / 非 YottaCode)。');
2932
+ }
2933
+ auditHostEvent(opts, {
2934
+ event: 'hosts.exclude',
2935
+ kind: result.entry.kind,
2936
+ value: result.entry.value,
2937
+ label: result.entry.label,
2938
+ });
2939
+ if (opts.json) {
2940
+ out(JSON.stringify({ action: 'hosts.exclude', entry: result.entry, already: Boolean(result.already) }, null, 2));
2941
+ return;
2942
+ }
2943
+ out((result.already ? '· 已在' : '✔ 已加入') + '「不接管」名单: ' + result.entry.label + '(' + result.entry.value + ')');
2944
+ out(' 发现 / 显示 / 链接三层全部跳过(--include-discovered 也不纳入)。');
2945
+ out(' 恢复接管:yotta-skills hub hosts include ' +
2946
+ (result.entry.kind === 'dir' ? '"' + result.entry.value + '"' : result.entry.value));
2947
+ }
2948
+
2949
+ /** 0.29.1 U2:恢复接管(移出「不接管」名单)。 */
2950
+ function runHostsInclude(opts) {
2951
+ const target = hostsSubcommandArg(opts, 2);
2952
+ if (!target) {
2953
+ die('hub hosts include 需要 <agentId|目录>', 2,
2954
+ '示例:yotta-skills hub hosts include box;或 hub hosts include "D:\\my-agent\\skills"。');
2955
+ }
2956
+ const registryOpts = hostRegistryOptions(opts);
2957
+ const result = hostsRegistryLib.includeHost(registryOpts, { target });
2958
+ if (!result.ok) {
2959
+ die(result.error, 2, '用 yotta-skills hub hosts list 或面板「不接管」名单查看当前条目。');
2960
+ }
2961
+ auditHostEvent(opts, {
2962
+ event: 'hosts.include',
2963
+ kind: result.removed.kind,
2964
+ value: result.removed.value,
2965
+ label: result.removed.label,
2966
+ });
2967
+ if (opts.json) {
2968
+ out(JSON.stringify({ action: 'hosts.include', removed: result.removed }, null, 2));
2969
+ return;
2970
+ }
2971
+ out('✔ 已恢复接管: ' + result.removed.label + '(下次发现 / 链接生效)');
2972
+ }
2973
+
2974
+ /** 0.29.1 U2:目录覆盖(hub hosts set <agentId> --dir <目录>;--clear 恢复默认)。 */
2975
+ function runHostsSet(opts) {
2976
+ const agentId = hostsSubcommandArg(opts, 2);
2977
+ if (!agentId) {
2978
+ die('hub hosts set 需要 <agentId>', 2,
2979
+ '示例:yotta-skills hub hosts set box --dir "D:\\box-agent\\skills";恢复默认加 --clear。');
2980
+ }
2981
+ const registryOpts = hostRegistryOptions(opts);
2982
+ if (opts.clear) {
2983
+ const result = hostsRegistryLib.clearHostOverride(registryOpts, { agentId });
2984
+ if (!result.ok) {
2985
+ die(result.error, 2, '示例:yotta-skills hub hosts set <agentId> --dir <目录>。');
2986
+ }
2987
+ auditHostEvent(opts, { event: 'hosts.set', agentId, cleared: true, dir: result.removed.dir });
2988
+ if (opts.json) {
2989
+ out(JSON.stringify({ action: 'hosts.set', cleared: true, removed: result.removed }, null, 2));
2990
+ return;
2991
+ }
2992
+ out('✔ 已恢复内置映射: ' + agentId);
2993
+ return;
2994
+ }
2995
+ const result = hostsRegistryLib.setHostOverride(registryOpts, { agentId, dir: opts.dir, label: opts.label });
2996
+ if (!result.ok) {
2997
+ die(result.error, 2, '目录需已存在,且不是 Hub / 锁桥接 / YottaCode 目录。');
2998
+ }
2999
+ auditHostEvent(opts, { event: 'hosts.set', agentId, dir: result.entry.dir, label: result.entry.label });
3000
+ if (opts.json) {
3001
+ out(JSON.stringify({ action: 'hosts.set', entry: result.entry }, null, 2));
3002
+ return;
3003
+ }
3004
+ out('✔ 已覆盖目录: ' + result.entry.label + ' -> ' + result.entry.dir);
3005
+ out(' 被覆盖的旧目录不再进入默认发现 / 链接;恢复默认:yotta-skills hub hosts set ' + agentId + ' --clear。');
3006
+ }
3007
+
2606
3008
  /**
2607
3009
  * 残留目录清理(F3):默认预览;--yes 执行。核心逻辑在 lib/host-purge.js
2608
3010
  * (CLI 与面板共用,fail-closed)。
@@ -2719,16 +3121,22 @@ function runWhere(opts) {
2719
3121
  }
2720
3122
  }
2721
3123
  out('默认安装位置: ' + info.defaultDir);
2722
- out('Hub 真源: ' + info.hubDir);
3124
+ out('Hub 真源: ' + info.hubDir + '(来源: ' + skillsConfigLib.sourceLabel(info.hubSource) + ')');
3125
+ if (info.hubConfigured && info.hubSource !== 'config') {
3126
+ out('配置覆盖: ' + info.hubConfigured + '(当前被更高优先级来源覆盖)');
3127
+ }
2723
3128
  }
2724
3129
 
2725
3130
  // ── view:本机技能枢纽面板(127.0.0.1,只读 API + 预览确认动作) ───────────
2726
3131
  function runView(opts) {
2727
3132
  const home = os.homedir();
2728
- const hubDir = hubLib.resolveHubDir(opts);
3133
+ const hubResolved = hubLib.resolveHub({ hub: opts.hub, homeDir: home, env: process.env });
3134
+ const hubDir = hubResolved.dir;
2729
3135
  const port = opts.port === null || opts.port === undefined ? hubViewServerLib.DEFAULT_PORT : opts.port;
2730
3136
  const view = hubViewServerLib.createHubViewServer({
2731
3137
  hubDir,
3138
+ hubSource: hubResolved.source,
3139
+ hubFlag: Boolean(opts.hub),
2732
3140
  homeDir: home,
2733
3141
  env: process.env,
2734
3142
  manifest: MANIFEST,
@@ -2833,8 +3241,28 @@ function main() {
2833
3241
  out('版本策略: ' + (opts.pin ? 'pin(锁死)' : 'range(' + skillRange(skills[0]) + ')'));
2834
3242
  out('');
2835
3243
  for (const s of skills) {
2836
- const tag = command === 'update' ? '将检查/更新' : '将安装';
2837
- out(' [' + tag + '] ' + s.slug.padEnd(22) + s.name.padEnd(5) + ' ' + specOf(s, opts.pin).padEnd(52) + ' ' + s.version + ' ' + s.desc);
3244
+ let tag = command === 'update' ? '将检查/更新' : '将安装';
3245
+ let suffix = '';
3246
+ if (command === 'update' && dest && !opts.force && opts.pin !== false) {
3247
+ const existing = readInstalledVersion(path.join(dest, s.slug));
3248
+ const relation = versionRelation(existing, s.version);
3249
+ if (relation === 'equal') {
3250
+ tag = '已最新';
3251
+ suffix = '(跳过,v' + existing + ')';
3252
+ } else if (relation === 'local-ahead') {
3253
+ tag = '本地领先';
3254
+ suffix = '(保留不降级:v' + existing + ' > v' + s.version + ')';
3255
+ } else if (relation === 'unknown') {
3256
+ tag = '无法比较';
3257
+ suffix = '(保留:本地 v' + (existing || '未知') + ')';
3258
+ } else if (relation === 'upgrade') {
3259
+ tag = '将更新';
3260
+ suffix = '(v' + existing + ' -> v' + s.version + ')';
3261
+ } else if (relation === 'install') {
3262
+ tag = '将安装';
3263
+ }
3264
+ }
3265
+ out(' [' + tag + '] ' + s.slug.padEnd(22) + s.name.padEnd(5) + ' ' + specOf(s, opts.pin).padEnd(52) + ' ' + s.version + ' ' + s.desc + suffix);
2838
3266
  }
2839
3267
  out('');
2840
3268
  out('(dry-run 未执行任何下载/写入)');