@yottameta/yotta-skills-plugin 0.29.3 → 0.29.5
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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/yotta-skills/SKILL.md +8 -4
- package/skills/yotta-skills/assets/view.html +191 -11
- package/skills/yotta-skills/bin/yotta-skills.js +286 -87
- package/skills/yotta-skills/lib/cli-help.js +7 -2
- package/skills/yotta-skills/lib/hub-adopt.js +123 -24
- package/skills/yotta-skills/lib/hub-migrate.js +212 -1
- package/skills/yotta-skills/lib/hub-targets.js +85 -0
- package/skills/yotta-skills/lib/hub-view-server.js +238 -7
- package/skills/yotta-skills/lib/hub.js +198 -9
- package/skills/yotta-skills/lib/install-pipeline.js +53 -5
- package/skills/yotta-skills/lib/npm-pack.js +3 -0
- package/skills/yotta-skills/lib/skills-config.js +99 -5
- package/skills/yotta-skills/lib/skills-scan.js +3 -0
- package/skills/yotta-skills/references/hub.md +23 -4
- package/skills/yotta-skills/references/skill-list.md +1 -1
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
- package/skills/yotta-skills/skills.json +2 -2
|
@@ -50,6 +50,7 @@ const agentDiscoveryLib = require('../lib/agent-discovery');
|
|
|
50
50
|
const hubLib = require('../lib/hub');
|
|
51
51
|
const hubAdoptLib = require('../lib/hub-adopt');
|
|
52
52
|
const hubMigrateLib = require('../lib/hub-migrate');
|
|
53
|
+
const hubTargetsLib = require('../lib/hub-targets');
|
|
53
54
|
const selfInstallLib = require('../lib/self-install');
|
|
54
55
|
const skillsConfigLib = require('../lib/skills-config');
|
|
55
56
|
const hostsRegistryLib = require('../lib/hosts-registry');
|
|
@@ -61,11 +62,11 @@ const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
|
|
|
61
62
|
const { versionRelation } = require('../lib/skills-scan');
|
|
62
63
|
|
|
63
64
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
64
|
-
let VERSION = '0.29.
|
|
65
|
+
let VERSION = '0.29.5';
|
|
65
66
|
try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
|
|
66
67
|
|
|
67
68
|
// @generated view-html:start
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')\n .replace(/\"/g, '"').replace(/'/g, ''');\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 <path> --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 > 环境变量 > 默认位置的解析。不会移动任何文件。</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";
|
|
69
|
+
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 <div class=\"section\" id=\"ovSwitchBanner\" hidden></div>\n <div class=\"section panel\" id=\"ovMigration\" hidden>\n <h3>最近一次迁移</h3>\n <div id=\"ovMigrationBody\"></div>\n <div class=\"toolbar section\">\n <button class=\"btn small danger\" id=\"migrationRollbackBtn\">回滚到原位置</button>\n <button class=\"btn small\" id=\"copyMigrationTrash\">复制回收站路径</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, '&').replace(/</g, '<').replace(/>/g, '>')\n .replace(/\"/g, '"').replace(/'/g, ''');\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 renderMigrationBlock(d);\n }\n\n /* 0.29.5 S1/S2:迁移向导(迁移 / 仅切换)+ 最近迁移 + 一键回滚(与 CLI 同内核) */\n function renderMigrationBlock(d) {\n var mig = (d.migration && d.migration.last) || null;\n var pending = (d.migration && d.migration.switchPending) || null;\n var banner = $('#ovSwitchBanner');\n if (pending) {\n banner.hidden = false;\n banner.innerHTML = '<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>' +\n '<div><div class=\"banner-title\">旧 Hub 还有 ' + esc(pending.skills) + ' 个技能未迁移</div>' +\n '<div class=\"banner-body\">旧位置 <span class=\"mono\">' + esc(pending.from) + '</span> · 当前位置 <span class=\"mono\">' + esc(d.hubDir) + '</span></div>' +\n '<div class=\"toolbar section\"><button class=\"btn small primary\" id=\"switchMigrateNow\">一键迁移到当前位置</button>' +\n '<button class=\"btn small\" id=\"switchMigrateOpen\">迁移到其它位置…</button></div></div></div>';\n $('#switchMigrateNow').onclick = function () {\n confirmModal('迁移旧 Hub 到当前位置?',\n '<p class=\"label\">将把 <span class=\"mono\">' + esc(pending.from) + '</span>(' + esc(pending.skills) + ' 个技能)复制到 <span class=\"mono\">' + esc(d.hubDir) + '</span>,逐技能校验后切换配置、重链全部宿主,旧 Hub 入回收站(7 天可回滚)。</p>',\n '开始迁移', false, async function () {\n await executeHubMigration({ hub: d.hubDir, mode: 'move' }, { from: pending.from });\n });\n };\n $('#switchMigrateOpen').onclick = function () { openHubConfigWizard(); };\n } else {\n banner.hidden = true;\n banner.innerHTML = '';\n }\n var box = $('#ovMigration');\n if (!mig) {\n box.hidden = true;\n return;\n }\n box.hidden = false;\n var kind = mig.kind === 'rollback' ? '回滚' : '迁移';\n var lines = [\n '最近一次' + kind + ':' + esc(mig.from) + ' → ' + esc(mig.to),\n '时间 ' + esc(fmtTime(mig.at)) + ' · 技能 ' + esc(mig.verifiedSkills) + ' · 重链 ' + esc(mig.relinkDirs) + ' 个宿主目录',\n ];\n if (mig.trashedTo) lines.push('回收站 ' + esc(mig.trashedTo) + '(剩余 ' + esc(mig.daysLeft) + ' 天)');\n if (mig.incomplete > 0) lines.push('⚠ 重链未全部完成(' + esc(mig.incomplete) + ' 项);旧 Hub 保留在 ' + esc(mig.oldHubKept || mig.from));\n if (mig.trashError) lines.push('⚠ 旧 Hub 移入回收站失败(已保留原位):' + esc(mig.trashError));\n $('#ovMigrationBody').innerHTML = '<div class=\"faint\">' + lines.join('<br>') + '</div>';\n $('#copyMigrationTrash').hidden = !mig.trashedTo;\n $('#copyMigrationTrash').onclick = function () { copyText(mig.trashedTo); };\n $('#migrationRollbackBtn').onclick = rollbackFlow;\n }\n\n function rewireModalButtons(opts) {\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n var cancel = $('#modal').querySelector('[data-modal=\"cancel\"]');\n if (ok && opts.okText) { ok.textContent = opts.okText; ok.disabled = false; ok.onclick = opts.onOk || closeModal; }\n if (ok && opts.hideOk) ok.hidden = true;\n if (cancel && opts.cancelText) { cancel.textContent = opts.cancelText; cancel.onclick = opts.onCancel || closeModal; }\n }\n\n function showMigrationResult(result) {\n var move = result.move || {};\n var done = !move.incomplete && !move.trashError;\n $('#modalBody').innerHTML =\n '<div class=\"result\"><span class=\"badge ' + (done ? 'ok' : 'danger') + '\">' + (done ? '迁移完成' : '部分完成') + '</span><span>' + esc(result.previous || '') + ' → ' + esc(result.hub || '') + '</span></div>' +\n '<div class=\"result\"><span class=\"badge ok\">校验</span><span>' + esc(move.verifiedSkills || 0) + ' 个技能复制校验通过</span></div>' +\n '<div class=\"result\"><span class=\"badge ok\">重链</span><span>' + esc(move.relinkDirs || 0) + ' 个宿主目录</span></div>' +\n (move.trashedTo ? '<div class=\"result\"><span class=\"badge ok\">旧 Hub</span><span class=\"mono\">' + esc(move.trashedTo) + '</span><span>已入回收站(7 天可恢复)</span></div>' : '') +\n (move.incomplete > 0 ? '<div class=\"result\"><span class=\"badge danger\">重链未全部完成</span><span>旧 Hub 保留在 <span class=\"mono\">' + esc(move.oldHubKept || move.from || '') + '</span>;重跑:yotta-skills hub link --all --force</span></div>' : '') +\n (move.trashError ? '<div class=\"result\"><span class=\"badge warn\">回收站</span><span>旧 Hub 移入回收站失败(已保留原位):' + esc(move.trashError) + '</span></div>' : '') +\n '<div class=\"note\">新位置从下次面板加载开始生效(已在运行的 view 需重启)。</div>';\n rewireModalButtons({\n okText: '关闭',\n onOk: closeModal,\n cancelText: '刷新面板',\n onCancel: function () { closeModal(); loadOverview(); },\n });\n }\n\n function showSwitchResult(result) {\n $('#modalBody').innerHTML =\n '<div class=\"result\"><span class=\"badge ok\">已切换</span><span>' + esc(result.previous || '') + ' → ' + esc(result.hub || '') + '</span></div>' +\n ((result.oldSkills || 0) > 0\n ? '<div class=\"result\"><span class=\"badge warn\">未迁移</span><span>旧 Hub 还有 ' + esc(result.oldSkills) + ' 个技能未迁移;概览会持续提示,可一键迁移。</span></div>'\n : '') +\n '<div class=\"note\">只切换了指针,没有移动任何文件;新位置从下次面板加载开始生效。</div>';\n rewireModalButtons({\n okText: '关闭',\n onOk: closeModal,\n cancelText: '刷新面板',\n onCancel: function () { closeModal(); loadOverview(); },\n });\n }\n\n async function executeHubMigration(body, opts) {\n opts = opts || {};\n var seq = showLoad(body.mode === 'switch' ? '正在切换 Hub 位置…' : '正在迁移 Hub(复制 → 校验 → 重链 → 回收站)…');\n var payload = Object.assign({ confirm: 'hub-config' }, body);\n if (opts.from) payload = Object.assign(payload, { from: opts.from });\n var r = await api('/api/hub/config', { body: payload });\n hideLoad(seq);\n if (r.error) {\n if (r.code === 'target-residue') return wizardResiduePrompt(body, r.error, opts);\n if (opts.inModal) {\n $('#hubWizardResult').innerHTML = '<div class=\"result\"><span class=\"badge danger\">失败</span><span>' + esc(r.error) + '</span></div>';\n } else {\n toast(r.error);\n }\n return null;\n }\n if (opts.inModal) {\n if (body.mode === 'switch') showSwitchResult(r);\n else showMigrationResult(r);\n }\n else {\n closeModal();\n toast('迁移完成:校验 ' + ((r.move && r.move.verifiedSkills) || 0) + ' 个技能 · 重链 ' + ((r.move && r.move.relinkDirs) || 0) + ' 个目录' + (r.move && r.move.incomplete ? '(重链未全部完成,见概览提示)' : ''));\n loadOverview();\n }\n return r;\n }\n\n function wizardResiduePrompt(body, error, opts) {\n var host = opts && opts.inModal ? $('#hubWizardResult') : $('#modalBody');\n if (!host) { toast(error); return; }\n host.innerHTML = '<div class=\"result\"><span class=\"badge warn\">残留</span><span>' + esc(error) + '</span></div>' +\n '<button class=\"btn small primary\" id=\"hubResidueClean\">清理残留并迁移</button>';\n $('#hubResidueClean').onclick = async function () {\n var seq = showLoad('正在清理残留并迁移…');\n var payload = Object.assign({ confirm: 'hub-config', cleanResidue: true }, body);\n if (opts && opts.from) payload.from = opts.from;\n var r = await api('/api/hub/config', { body: payload });\n hideLoad(seq);\n if (r.error) { toast(r.error); return; }\n if (opts && opts.inModal) showMigrationResult(r);\n else { closeModal(); toast('迁移完成'); loadOverview(); }\n };\n }\n\n function openHubConfigWizard() {\n var d = state.overview || {};\n var current = d.hubDir || '';\n var skillCount = (d.status && d.status.summary && d.status.summary.skills) || 0;\n var body = '<p class=\"label\">写入 config.json 持久覆盖(优先级低于 --hub 参数 / YOTTA_SKILLS_HUB)。</p>' +\n '<label class=\"label\">新的 Hub 目录</label>' +\n '<input id=\"hubNewPath\" placeholder=\"D:\\\\my-hub\" value=\"\">' +\n '<div class=\"note mono\">当前: ' + esc(current) + '(' + esc(skillCount) + ' 个技能)</div>' +\n '<div class=\"section\"><label class=\"label\"><input type=\"radio\" name=\"hubMode\" value=\"move\" checked style=\"width:auto\"> 迁移到新位置(推荐)</label>' +\n '<div class=\"faint\">复制 → 逐技能校验 → 切换配置 → 全宿主重链 → 旧 Hub 入回收站(7 天可回滚)。</div>' +\n '<label class=\"label section\"><input type=\"radio\" name=\"hubMode\" value=\"switch\" style=\"width:auto\"> 仅切换位置(保留旧内容)</label>' +\n '<div class=\"faint\">只改指针,不移动任何文件;旧 Hub 内容保留,概览会提示未迁移。</div></div>' +\n '<div id=\"hubWizardResult\" class=\"section\"></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 mode = document.querySelector('input[name=\"hubMode\"]:checked').value;\n if (mode === 'switch' && skillCount > 0) {\n $('#hubWizardResult').innerHTML = '<div class=\"result\"><span class=\"badge warn\">确认</span><span>旧 Hub 还有 ' + esc(skillCount) + ' 个技能未迁移。仅切换位置不会移动它们。</span></div>' +\n '<button class=\"btn small danger\" id=\"hubSwitchConfirm\">确认仅切换</button>';\n $('#hubSwitchConfirm').onclick = function () { executeHubMigration({ hub: value, mode: 'switch' }, { inModal: true }); };\n return;\n }\n await executeHubMigration({ hub: value, mode: mode }, { inModal: true });\n },\n });\n }\n\n function rollbackFlow() {\n api('/api/hub/config').then(function (d) {\n if (d.error) { toast(d.error); return; }\n var rb = d.rollback || {};\n if (!rb.lastMigration) { toast('没有可回滚的迁移记录'); return; }\n if (!rb.ok) {\n confirmModal('回滚被阻断', '<p class=\"label\">' + esc((rb.blocked || []).join(';') || rb.error || '') + '</p><div class=\"note\">回滚 fail-closed(不合并);处理阻断项后重试。</div>', null, true, null);\n return;\n }\n confirmModal('回滚 Hub 位置?',\n '<p class=\"label\">将把当前 Hub 内容复制回原位置:<span class=\"mono\">' + esc(rb.current) + '</span> → <span class=\"mono\">' + esc(rb.target) + '</span>(复制校验 → 切换 → 重链 → 当前 Hub 入回收站 7 天)。</p>' +\n '<div class=\"note\">位置回退(用当前内容),不是恢复旧快照。</div>',\n '执行回滚', true, async function () {\n var seq = showLoad('正在回滚 Hub 位置(复制 → 校验 → 重链 → 回收站)…');\n var r = await api('/api/hub/config', { body: { rollback: true, confirm: 'hub-config' } });\n hideLoad(seq);\n if (r.error) {\n if (r.code === 'target-residue') { wizardResiduePrompt({ rollback: true }, r.error, {}); return; }\n toast(r.error); return;\n }\n closeModal();\n toast('回滚完成:' + ((r.move && r.move.verifiedSkills) || 0) + ' 个技能已回到 ' + (r.hub || ''));\n loadOverview();\n });\n });\n }\n\n $('#hubConfigBtn').onclick = openHubConfigWizard;\n $('#hubConfigClearBtn').onclick = function () {\n confirmModal('清除 Hub 位置覆盖?', '<p class=\"label\">将删除 config.json 中的 Hub 覆盖,恢复 --hub > 环境变量 > 默认位置的解析。不会移动任何文件。</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";
|
|
69
70
|
// @generated view-html:end
|
|
70
71
|
|
|
71
72
|
function loadManifest() {
|
|
@@ -226,6 +227,7 @@ function parseArgs(argv) {
|
|
|
226
227
|
else if (a === '--id') opts.id = take('--id').toLowerCase();
|
|
227
228
|
else if (a === '--clear') opts.clear = true;
|
|
228
229
|
else if (a === '--move') opts.move = true;
|
|
230
|
+
else if (a === '--clean-residue') opts.cleanResidue = true;
|
|
229
231
|
else if (a === '--purge') opts.purge = true;
|
|
230
232
|
else if (a === '--all') opts.all = true;
|
|
231
233
|
else if (a === '--include-discovered') opts.includeDiscovered = true;
|
|
@@ -321,6 +323,11 @@ function parseArgs(argv) {
|
|
|
321
323
|
die('--move 只能与 hub config set 一起使用', 2,
|
|
322
324
|
'示例:yotta-skills hub config set --hub "D:\\my-hub" --move。');
|
|
323
325
|
}
|
|
326
|
+
if (opts.cleanResidue && !(opts.command === 'hub' && opts.hubAction === 'config' &&
|
|
327
|
+
(opts.skills[0] === 'set' || opts.skills[0] === 'rollback'))) {
|
|
328
|
+
die('--clean-residue 只能与 hub config set --move / hub config rollback 一起使用', 2,
|
|
329
|
+
'示例:yotta-skills hub config set --hub "D:\\my-hub" --move --clean-residue。');
|
|
330
|
+
}
|
|
324
331
|
return opts;
|
|
325
332
|
}
|
|
326
333
|
|
|
@@ -736,6 +743,15 @@ function recordTrustedVerifier(dest) {
|
|
|
736
743
|
return record;
|
|
737
744
|
}
|
|
738
745
|
|
|
746
|
+
/**
|
|
747
|
+
* 元信自举运行时缓存(0.29.5 S4):与受信记录同根,默认 ~/.yottaskills/runtime/verifier。
|
|
748
|
+
* 自举产物不再写进目标目录(--dir 托管目录保持干净);后续门禁通过受信记录
|
|
749
|
+
* (路径 + SHA-256)使用缓存副本。
|
|
750
|
+
*/
|
|
751
|
+
function verifierRuntimeRoot() {
|
|
752
|
+
return path.join(path.dirname(trustedVerifierLib.recordPath()), 'runtime', 'verifier');
|
|
753
|
+
}
|
|
754
|
+
|
|
739
755
|
function findPython(opts) {
|
|
740
756
|
return hubScanLib.findPython(opts);
|
|
741
757
|
}
|
|
@@ -921,8 +937,12 @@ function ensureGate(context) {
|
|
|
921
937
|
const engine = path.join(extractedVerifier.pkgDir, 'scripts', 'yotta_verify.py');
|
|
922
938
|
if (!fs.existsSync(engine)) return { ok: false, error: '元信包内缺少 scripts/yotta_verify.py' };
|
|
923
939
|
|
|
924
|
-
|
|
925
|
-
|
|
940
|
+
// 0.29.5 S4:自举落点改为运行时缓存(不污染目标目录;如 yottacode 的 skill-inventory)。
|
|
941
|
+
// 安全边界不变:目标目录里未受信记录的元信副本一律不用(v0.19.13 反劫持模型),
|
|
942
|
+
// 受信记录匹配(含记录指向目标目录副本)时才直接使用。
|
|
943
|
+
const runtimeDir = verifierRuntimeRoot();
|
|
944
|
+
out(' 元信未安装,使用可信源包自举到运行时缓存(' + runtimeDir + ')并先自扫。');
|
|
945
|
+
const result = installOne(verifier, runtimeDir, {
|
|
926
946
|
...opts,
|
|
927
947
|
force: true,
|
|
928
948
|
skipScan: false,
|
|
@@ -930,9 +950,9 @@ function ensureGate(context) {
|
|
|
930
950
|
verify: engine,
|
|
931
951
|
});
|
|
932
952
|
if (result.status !== 'ok') return { ok: false, error: '元信自举失败: ' + result.note };
|
|
933
|
-
const installedEngine = path.join(
|
|
953
|
+
const installedEngine = path.join(runtimeDir, 'yotta-verify', 'scripts', 'yotta_verify.py');
|
|
934
954
|
if (!fs.existsSync(installedEngine)) return { ok: false, error: '元信自举后未找到安装引擎' };
|
|
935
|
-
if (!recordTrustedVerifier(
|
|
955
|
+
if (!recordTrustedVerifier(runtimeDir)) {
|
|
936
956
|
return { ok: false, error: '元信自举后身份 / 摘要校验未通过(fail-closed,不执行该引擎)' };
|
|
937
957
|
}
|
|
938
958
|
return { ok: true, engine: installedEngine, mode: 'trusted-bootstrap' };
|
|
@@ -1937,21 +1957,11 @@ function runHook(opts) {
|
|
|
1937
1957
|
|
|
1938
1958
|
// ── Hub(本机单点安装 + 链接分发) ─────────────────────────────────────────
|
|
1939
1959
|
function universalSkillDirs() {
|
|
1940
|
-
|
|
1941
|
-
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
1942
|
-
return [
|
|
1943
|
-
{ dir: path.join(xdg, 'agents', 'skills'), agentId: 'universal', label: 'Universal .agents' },
|
|
1944
|
-
{ dir: path.join(home, '.agents', 'skills'), agentId: 'agents', label: '通用 AGENTS.md' },
|
|
1945
|
-
];
|
|
1960
|
+
return hubTargetsLib.universalSkillDirs({ homeDir: os.homedir(), env: process.env });
|
|
1946
1961
|
}
|
|
1947
1962
|
|
|
1948
1963
|
function normalizeTargetKey(dir) {
|
|
1949
|
-
|
|
1950
|
-
let real = resolved;
|
|
1951
|
-
try {
|
|
1952
|
-
real = fs.realpathSync.native(resolved);
|
|
1953
|
-
} catch (_) { /* missing path: fall back to the resolved spelling */ }
|
|
1954
|
-
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
1964
|
+
return hubTargetsLib.normalizeTargetKey(dir);
|
|
1955
1965
|
}
|
|
1956
1966
|
|
|
1957
1967
|
function hubScopeSummary(discovery) {
|
|
@@ -2041,16 +2051,15 @@ function hubTargetDirs(opts, discovery) {
|
|
|
2041
2051
|
'示例:yotta-skills hub link --all;或 yotta-skills hub link --agent codex。');
|
|
2042
2052
|
}
|
|
2043
2053
|
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
add(host.dir, host.agentId, host.label, host);
|
|
2054
|
+
// 0.29.5:默认范围与面板迁移重链共用 lib/hub-targets.js 单一真源。
|
|
2055
|
+
for (const item of hubTargetsLib.computeHubTargets({
|
|
2056
|
+
discovery,
|
|
2057
|
+
registry,
|
|
2058
|
+
homeDir: os.homedir(),
|
|
2059
|
+
env: process.env,
|
|
2060
|
+
includeDiscovered: opts.includeDiscovered,
|
|
2061
|
+
})) {
|
|
2062
|
+
add(item.dir, item.agentId, item.label, item);
|
|
2054
2063
|
}
|
|
2055
2064
|
return targets;
|
|
2056
2065
|
}
|
|
@@ -2253,7 +2262,8 @@ function printAdoptScan(payload, json) {
|
|
|
2253
2262
|
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 收编预演(只读)');
|
|
2254
2263
|
out('Hub: ' + payload.hubDir);
|
|
2255
2264
|
out('候选 ' + payload.summary.candidates + ' / 冲突 ' + payload.summary.conflicts +
|
|
2256
|
-
' / 已在 Hub ' + payload.summary.alreadyInHub
|
|
2265
|
+
' / 已在 Hub ' + payload.summary.alreadyInHub +
|
|
2266
|
+
(payload.summary.skipped ? ' / 跳过 ' + payload.summary.skipped : ''));
|
|
2257
2267
|
out('');
|
|
2258
2268
|
for (const item of payload.candidates) {
|
|
2259
2269
|
const mark = item.conflict ? '△' : item.inHub ? '·' : '✔';
|
|
@@ -2268,6 +2278,13 @@ function printAdoptScan(payload, json) {
|
|
|
2268
2278
|
}
|
|
2269
2279
|
}
|
|
2270
2280
|
if (payload.candidates.length === 0) out(' (没有发现可收编技能)');
|
|
2281
|
+
if ((payload.skipped || []).length) {
|
|
2282
|
+
out('');
|
|
2283
|
+
out('跳过 ' + payload.skipped.length + ' 个不可读候选(坏链 / 竞态删除;只读扫描不中断):');
|
|
2284
|
+
for (const item of payload.skipped) {
|
|
2285
|
+
out(' ✘ ' + item.slug + ' ' + item.dir + '(' + item.reason + ')');
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2271
2288
|
}
|
|
2272
2289
|
|
|
2273
2290
|
function printAdoptApply(payload, json) {
|
|
@@ -2469,6 +2486,61 @@ function runHub(opts) {
|
|
|
2469
2486
|
return;
|
|
2470
2487
|
}
|
|
2471
2488
|
|
|
2489
|
+
if (action === 'cleanup-backups') {
|
|
2490
|
+
// 0.29.5 S3:历史备份 / 暂存残留清理(默认预览;--yes 执行;入回收站 7 天)。
|
|
2491
|
+
let dirs;
|
|
2492
|
+
if (opts.dir || opts.agent) {
|
|
2493
|
+
dirs = hubTargetDirs(opts, discovery).map((item) => ({ dir: item.dir, label: item.label }));
|
|
2494
|
+
} else {
|
|
2495
|
+
dirs = [{ dir: hubDir, label: 'Hub' }].concat(
|
|
2496
|
+
hubTargetDirs({ all: true, hubAction: 'cleanup-backups', includeDiscovered: opts.includeDiscovered }, discovery)
|
|
2497
|
+
.filter((item) => fs.existsSync(item.dir))
|
|
2498
|
+
.map((item) => ({ dir: item.dir, label: item.label })),
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
const seenDirs = new Set();
|
|
2502
|
+
dirs = dirs.filter((item) => {
|
|
2503
|
+
const key = normalizeTargetKey(item.dir);
|
|
2504
|
+
if (seenDirs.has(key)) return false;
|
|
2505
|
+
seenDirs.add(key);
|
|
2506
|
+
return true;
|
|
2507
|
+
});
|
|
2508
|
+
const residues = hubLib.findBackupResidues({ dirs });
|
|
2509
|
+
if (!opts.yes) {
|
|
2510
|
+
if (opts.json) {
|
|
2511
|
+
out(JSON.stringify({
|
|
2512
|
+
action: 'cleanup-backups',
|
|
2513
|
+
preview: true,
|
|
2514
|
+
dirs: dirs.map((item) => item.dir),
|
|
2515
|
+
count: residues.length,
|
|
2516
|
+
residues,
|
|
2517
|
+
}, null, 2));
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 历史备份 / 暂存残留清理(预览)');
|
|
2521
|
+
out('范围: ' + dirs.length + ' 个目录(Hub + 已核实宿主)');
|
|
2522
|
+
if (residues.length === 0) {
|
|
2523
|
+
out('未发现 .yottaskills-backup-* / .yottaskills-import-* / .yottaskills-rollback-* / .yottaskills-staging 残留。');
|
|
2524
|
+
} else {
|
|
2525
|
+
for (const item of residues) out(' △ ' + item.path);
|
|
2526
|
+
out('共 ' + residues.length + ' 项;确认后加 --yes 执行(移入回收站,保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)。');
|
|
2527
|
+
}
|
|
2528
|
+
out('(预览未执行任何变更)');
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
const result = hubLib.cleanupBackupResidues({ dirs, hubDir, env: process.env, residues });
|
|
2532
|
+
if (opts.json) {
|
|
2533
|
+
out(JSON.stringify({ action: 'cleanup-backups', preview: false, ...result }, null, 2));
|
|
2534
|
+
} else {
|
|
2535
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 历史备份 / 暂存残留清理');
|
|
2536
|
+
out('✔ 已清理 ' + result.moved.length + ' / ' + result.found + ' 项 → 回收站: ' + result.trashRoot +
|
|
2537
|
+
'(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2538
|
+
for (const item of result.failed) out(' ✘ ' + item.path + ':' + item.error);
|
|
2539
|
+
}
|
|
2540
|
+
if (!result.ok) process.exitCode = 1;
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2472
2544
|
if (action === 'link') {
|
|
2473
2545
|
const targets = hubTargetDirs(opts, discovery);
|
|
2474
2546
|
if (opts.all && !opts.json) out(hubScopeLine(opts, discovery));
|
|
@@ -2506,6 +2578,11 @@ function runHub(opts) {
|
|
|
2506
2578
|
out(' 收敛: 移动 ' + movedCount + ' 份旧副本 -> ' + (result.trashRoot || '') +
|
|
2507
2579
|
'(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2508
2580
|
}
|
|
2581
|
+
const replacedCount = result.results.filter((item) => item.backup).length;
|
|
2582
|
+
if (replacedCount > 0) {
|
|
2583
|
+
out(' 替换: ' + replacedCount + ' 份真目录旧副本已移入回收站 -> ' + (result.trashRoot || '') +
|
|
2584
|
+
'(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2585
|
+
}
|
|
2509
2586
|
} else {
|
|
2510
2587
|
failed += result.results.filter((item) => item.status === 'error').length;
|
|
2511
2588
|
}
|
|
@@ -2603,7 +2680,7 @@ function runHub(opts) {
|
|
|
2603
2680
|
}
|
|
2604
2681
|
|
|
2605
2682
|
die('未知 hub 子命令: ' + action, 2,
|
|
2606
|
-
'支持 install / update / adopt / refresh / link / unlink / remove / status / hosts / config / doctor。');
|
|
2683
|
+
'支持 install / update / adopt / refresh / link / unlink / remove / status / hosts / config / doctor / cleanup-backups。');
|
|
2607
2684
|
}
|
|
2608
2685
|
|
|
2609
2686
|
// ── hub hosts add / remove / list / mark(0.29.0 F2/F3 注册表与手动标记) ─────
|
|
@@ -2626,13 +2703,52 @@ function auditHubConfigEvent(hubDir, entry) {
|
|
|
2626
2703
|
} catch (_) { /* 审计失败不阻断配置主流程 */ }
|
|
2627
2704
|
}
|
|
2628
2705
|
|
|
2629
|
-
/**
|
|
2706
|
+
/** 旧 Hub / 目标目录的技能计数(只读;用于「未迁移」提示与切换记录)。 */
|
|
2707
|
+
function countHubSkills(dir) {
|
|
2708
|
+
try {
|
|
2709
|
+
return hubLib.scanHubSkills(dir).length;
|
|
2710
|
+
} catch (_) {
|
|
2711
|
+
return 0;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/** 迁移 / 回滚的重链范围(与 hub link --all 同一口径,只取存在的目录)。 */
|
|
2716
|
+
function hubMigrationTargets() {
|
|
2717
|
+
const discovery = agentDiscoveryLib.discoverHosts({ homeDir: os.homedir(), env: process.env });
|
|
2718
|
+
return hubTargetDirs({ all: true, hubAction: 'config' }, discovery)
|
|
2719
|
+
.filter((item) => fs.existsSync(item.dir));
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/** 「最近一次迁移」文本块(CLI 与输出口径;面板走 JSON 同源字段)。 */
|
|
2723
|
+
function migrationLines(migration) {
|
|
2724
|
+
const kindLabel = migration.kind === 'rollback' ? '回滚' : '迁移';
|
|
2725
|
+
const lines = [];
|
|
2726
|
+
lines.push('最近一次' + kindLabel + ':');
|
|
2727
|
+
if (migration.at) lines.push(' 时间: ' + migration.at);
|
|
2728
|
+
lines.push(' ' + migration.from + ' → ' + migration.to);
|
|
2729
|
+
lines.push(' 技能: ' + migration.verifiedSkills + ' 个(复制校验通过);重链: ' +
|
|
2730
|
+
migration.relinkDirs + ' 个宿主目录');
|
|
2731
|
+
if (migration.trashedTo) {
|
|
2732
|
+
lines.push(' 回收站: ' + migration.trashedTo + '(保留 ' + hubLib.TRASH_RETENTION_DAYS +
|
|
2733
|
+
' 天,剩余 ' + hubMigrateLib.trashDaysLeft(migration.at) + ' 天)');
|
|
2734
|
+
}
|
|
2735
|
+
if (migration.incomplete > 0) {
|
|
2736
|
+
lines.push(' ⚠ 重链未全部完成(' + migration.incomplete + ' 项);旧 Hub 保留在 ' +
|
|
2737
|
+
(migration.oldHubKept || migration.from) + ',可重跑:yotta-skills hub link --all --force');
|
|
2738
|
+
}
|
|
2739
|
+
if (migration.trashError) lines.push(' ⚠ 旧 Hub 移入回收站失败(已保留原位): ' + migration.trashError);
|
|
2740
|
+
lines.push(' 回滚: yotta-skills hub config rollback(预览)/ --yes 执行(位置回退,用当前内容)');
|
|
2741
|
+
return lines;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
/** 0.29.2 U4:hub config get / set / clear;0.29.5 增 rollback(面板同源内核)。 */
|
|
2630
2745
|
function runHubConfig(opts) {
|
|
2631
2746
|
const sub = opts.skills[0] || 'get';
|
|
2632
2747
|
const configOpts = { homeDir: os.homedir(), env: process.env };
|
|
2633
2748
|
|
|
2634
2749
|
if (sub === 'get') {
|
|
2635
2750
|
const resolved = hubLib.resolveHub({ ...configOpts, hub: opts.hub });
|
|
2751
|
+
const config = skillsConfigLib.readConfig(configOpts);
|
|
2636
2752
|
const payload = {
|
|
2637
2753
|
action: 'config.get',
|
|
2638
2754
|
hub: resolved.dir,
|
|
@@ -2640,13 +2756,31 @@ function runHubConfig(opts) {
|
|
|
2640
2756
|
configured: resolved.configured,
|
|
2641
2757
|
configFile: resolved.configFile,
|
|
2642
2758
|
defaultDir: resolved.defaultDir,
|
|
2759
|
+
lastMigration: config.lastMigration,
|
|
2760
|
+
lastSwitch: config.lastSwitch,
|
|
2643
2761
|
};
|
|
2762
|
+
if (config.lastMigration) payload.migrationDaysLeft = hubMigrateLib.trashDaysLeft(config.lastMigration.at);
|
|
2644
2763
|
if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
|
|
2645
2764
|
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 配置');
|
|
2646
2765
|
out('生效 Hub: ' + payload.hub + '(来源: ' + skillsConfigLib.sourceLabel(payload.source) + ')');
|
|
2647
2766
|
out('配置覆盖: ' + (payload.configured || '无'));
|
|
2648
2767
|
out('配置文件: ' + payload.configFile);
|
|
2649
2768
|
out('默认位置: ' + payload.defaultDir);
|
|
2769
|
+
if (config.lastMigration) {
|
|
2770
|
+
out('');
|
|
2771
|
+
for (const line of migrationLines(config.lastMigration)) out(line);
|
|
2772
|
+
} else {
|
|
2773
|
+
out('最近一次迁移: 无(hub config set --hub <path> --move 迁移后记录,可一键回滚)');
|
|
2774
|
+
}
|
|
2775
|
+
if (config.lastSwitch) {
|
|
2776
|
+
const oldCount = countHubSkills(config.lastSwitch.from);
|
|
2777
|
+
if (oldCount > 0) {
|
|
2778
|
+
out('');
|
|
2779
|
+
out('未迁移提示: 旧 Hub ' + config.lastSwitch.from + ' 还有 ' + oldCount +
|
|
2780
|
+
' 个技能未迁移(当前位置 ' + resolved.dir + ')');
|
|
2781
|
+
out(' 一键迁移: yotta-skills hub config set --hub "' + resolved.dir + '" --move');
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2650
2784
|
return;
|
|
2651
2785
|
}
|
|
2652
2786
|
|
|
@@ -2664,56 +2798,45 @@ function runHubConfig(opts) {
|
|
|
2664
2798
|
}
|
|
2665
2799
|
|
|
2666
2800
|
let moveReport = null;
|
|
2801
|
+
let migrationResult = null;
|
|
2667
2802
|
if (opts.move) {
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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
|
-
};
|
|
2803
|
+
migrationResult = hubMigrateLib.runHubMigration({
|
|
2804
|
+
from: current.dir,
|
|
2805
|
+
to: target,
|
|
2806
|
+
targets: hubMigrationTargets(),
|
|
2807
|
+
manifest: MANIFEST,
|
|
2808
|
+
homeDir: os.homedir(),
|
|
2809
|
+
env: process.env,
|
|
2810
|
+
configOpts,
|
|
2811
|
+
via: 'cli',
|
|
2812
|
+
cleanResidue: Boolean(opts.cleanResidue),
|
|
2813
|
+
});
|
|
2814
|
+
if (!migrationResult.ok && migrationResult.phase !== 'relink') {
|
|
2815
|
+
const hint = migrationResult.code === 'target-residue'
|
|
2816
|
+
? '目标目录只含空 Hub 台账残留;确认后可加 --clean-residue 清理后迁移。'
|
|
2817
|
+
: '迁移 fail-closed:配置未切换、旧 Hub 未动;可改用不带 --move 的 set。';
|
|
2818
|
+
die(migrationResult.error, migrationResult.phase === 'config' ? 1 : 2, hint);
|
|
2708
2819
|
}
|
|
2820
|
+
moveReport = migrationResult.report;
|
|
2709
2821
|
} else {
|
|
2710
2822
|
const setResult = skillsConfigLib.setHub(configOpts, { hub: target });
|
|
2711
2823
|
if (!setResult.ok) die(setResult.error, 2, '检查路径后重试。');
|
|
2824
|
+
const oldSkills = countHubSkills(current.dir);
|
|
2825
|
+
if (oldSkills > 0) {
|
|
2826
|
+
skillsConfigLib.recordLastSwitch(configOpts, {
|
|
2827
|
+
from: current.dir,
|
|
2828
|
+
to: target,
|
|
2829
|
+
at: new Date().toISOString(),
|
|
2830
|
+
skills: oldSkills,
|
|
2831
|
+
via: 'cli',
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2712
2834
|
moveReport = {
|
|
2713
2835
|
moved: false,
|
|
2714
2836
|
reason: '未指定 --move:仅切换指针(旧 Hub 内容保留)。填充新 Hub:hub install;重链:hub link --all --force',
|
|
2715
2837
|
trashedTo: null,
|
|
2716
2838
|
relink: [],
|
|
2839
|
+
oldSkills,
|
|
2717
2840
|
};
|
|
2718
2841
|
}
|
|
2719
2842
|
|
|
@@ -2735,24 +2858,100 @@ function runHubConfig(opts) {
|
|
|
2735
2858
|
moved: Boolean(moveReport && moveReport.moved),
|
|
2736
2859
|
via: 'cli',
|
|
2737
2860
|
});
|
|
2738
|
-
if (opts.json) {
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
out('
|
|
2744
|
-
if (moveReport
|
|
2745
|
-
out('
|
|
2861
|
+
if (opts.json) {
|
|
2862
|
+
out(JSON.stringify(payload, null, 2));
|
|
2863
|
+
} else {
|
|
2864
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 配置');
|
|
2865
|
+
out('✔ 已设置 Hub: ' + payload.hub + '(来源: ' + skillsConfigLib.sourceLabel(payload.source) + ')');
|
|
2866
|
+
out(' 配置文件: ' + payload.configFile);
|
|
2867
|
+
if (moveReport && moveReport.moved) {
|
|
2868
|
+
out(' 迁移: 复制校验 ' + moveReport.verifiedSkills + ' 个技能;重链 ' +
|
|
2869
|
+
moveReport.relinkDirs + ' 个宿主目录');
|
|
2870
|
+
if (moveReport.incomplete > 0) {
|
|
2871
|
+
out(' ⚠ 重链未全部完成(' + moveReport.incomplete + ' 项);旧 Hub 保留在 ' + moveReport.oldHubKept +
|
|
2872
|
+
',可重跑:yotta-skills hub link --all --force');
|
|
2873
|
+
} else if (moveReport.trashedTo) {
|
|
2874
|
+
out(' 旧 Hub 已移入回收站: ' + moveReport.trashedTo + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2875
|
+
} else if (moveReport.trashError) {
|
|
2876
|
+
out(' ⚠ 旧 Hub 移入回收站失败(已保留原位): ' + moveReport.trashError);
|
|
2877
|
+
}
|
|
2878
|
+
} else if (moveReport) {
|
|
2879
|
+
out(' ' + moveReport.reason);
|
|
2880
|
+
if (moveReport.oldSkills > 0) {
|
|
2881
|
+
out(' ⚠ 旧 Hub 还有 ' + moveReport.oldSkills + ' 个技能未迁移;迁移用:yotta-skills hub config set --hub "' +
|
|
2882
|
+
resolved.dir + '" --move');
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
out(' 提示: 已在运行的 view 面板需重启后生效。');
|
|
2886
|
+
}
|
|
2887
|
+
if (migrationResult && !migrationResult.ok) process.exitCode = 1;
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
if (sub === 'rollback') {
|
|
2892
|
+
const preview = hubMigrateLib.previewRollback({ configOpts, env: process.env });
|
|
2893
|
+
if (!preview.ok && preview.code === 'no-migration') {
|
|
2894
|
+
die(preview.error, 2, '先完成一次迁移:yotta-skills hub config set --hub <path> --move。');
|
|
2895
|
+
}
|
|
2896
|
+
if (!opts.yes) {
|
|
2897
|
+
const payload = { action: 'config.rollback', preview: true, ...preview };
|
|
2898
|
+
if (opts.json) out(JSON.stringify(payload, null, 2));
|
|
2899
|
+
else {
|
|
2900
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 迁移回滚(预览)');
|
|
2901
|
+
out('当前 Hub: ' + preview.current);
|
|
2902
|
+
out('回滚目标: ' + preview.target + (preview.targetState.exists ? '(' + (preview.targetState.empty ? '空目录' : preview.targetState.residueOnly ? '仅空台账残留' : '非空') + ')' : '(不存在,将新建)'));
|
|
2903
|
+
out('语义: 位置回退(用当前内容复制回原位置;不恢复旧快照)');
|
|
2904
|
+
if (preview.lastMigration.trashedTo) {
|
|
2905
|
+
out('上次迁移回收站: ' + preview.lastMigration.trashedTo + '(剩余 ' + preview.daysLeft + ' 天)');
|
|
2906
|
+
}
|
|
2907
|
+
if (!preview.ok) {
|
|
2908
|
+
for (const reason of preview.blocked) out('⚠ ' + reason);
|
|
2909
|
+
out('(预览未执行任何变更;处理阻断项后重试)');
|
|
2910
|
+
process.exitCode = 2;
|
|
2911
|
+
} else {
|
|
2912
|
+
out('执行: yotta-skills hub config rollback --yes');
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
if (!preview.ok) {
|
|
2918
|
+
die('回滚被阻断:' + preview.blocked.join(';'), 2,
|
|
2919
|
+
'回滚 fail-closed(不合并);目标非空时先清理目标,或继续使用当前位置。');
|
|
2920
|
+
}
|
|
2921
|
+
const migration = hubMigrateLib.runHubMigration({
|
|
2922
|
+
from: preview.current,
|
|
2923
|
+
to: preview.target,
|
|
2924
|
+
kind: 'rollback',
|
|
2925
|
+
targets: hubMigrationTargets(),
|
|
2926
|
+
manifest: MANIFEST,
|
|
2927
|
+
homeDir: os.homedir(),
|
|
2928
|
+
env: process.env,
|
|
2929
|
+
configOpts,
|
|
2930
|
+
via: 'cli',
|
|
2931
|
+
cleanResidue: Boolean(opts.cleanResidue),
|
|
2932
|
+
});
|
|
2933
|
+
if (!migration.ok && migration.phase !== 'relink') {
|
|
2934
|
+
const hint = migration.code === 'target-residue'
|
|
2935
|
+
? '目标目录只含空 Hub 台账残留;确认后可加 --clean-residue 清理后回滚。'
|
|
2936
|
+
: '回滚 fail-closed:配置未切换、当前 Hub 未动。';
|
|
2937
|
+
die(migration.error, migration.phase === 'config' ? 1 : 2, hint);
|
|
2938
|
+
}
|
|
2939
|
+
const report = migration.report;
|
|
2940
|
+
const payload = { action: 'config.rollback', preview: false, ...report };
|
|
2941
|
+
if (opts.json) out(JSON.stringify(payload, null, 2));
|
|
2942
|
+
else {
|
|
2943
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 迁移回滚');
|
|
2944
|
+
out('✔ 已回滚位置: ' + report.from + ' → ' + report.to);
|
|
2945
|
+
out(' 技能: 复制校验 ' + report.verifiedSkills + ' 个;重链 ' + report.relinkDirs + ' 个宿主目录');
|
|
2946
|
+
if (report.incomplete > 0) {
|
|
2947
|
+
out(' ⚠ 重链未全部完成(' + report.incomplete + ' 项);原 Hub 保留在 ' + report.oldHubKept +
|
|
2746
2948
|
',可重跑:yotta-skills hub link --all --force');
|
|
2747
|
-
} else if (
|
|
2748
|
-
out('
|
|
2749
|
-
} else if (moveReport.trashError) {
|
|
2750
|
-
out(' ⚠ 旧 Hub 移入回收站失败(已保留原位): ' + moveReport.trashError);
|
|
2949
|
+
} else if (report.trashedTo) {
|
|
2950
|
+
out(' 原 Hub 已移入回收站: ' + report.trashedTo + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2751
2951
|
}
|
|
2752
|
-
|
|
2753
|
-
out(' ' + moveReport.reason);
|
|
2952
|
+
out(' 提示: 已在运行的 view 面板需重启后生效。');
|
|
2754
2953
|
}
|
|
2755
|
-
|
|
2954
|
+
if (!migration.ok) process.exitCode = 1;
|
|
2756
2955
|
return;
|
|
2757
2956
|
}
|
|
2758
2957
|
|
|
@@ -2786,7 +2985,7 @@ function runHubConfig(opts) {
|
|
|
2786
2985
|
}
|
|
2787
2986
|
|
|
2788
2987
|
die('未知 hub config 子命令: ' + sub, 2,
|
|
2789
|
-
'支持 get / set / clear;示例:yotta-skills hub config set --hub "D:\\my-hub" --move。');
|
|
2988
|
+
'支持 get / set / clear / rollback;示例:yotta-skills hub config set --hub "D:\\my-hub" --move。');
|
|
2790
2989
|
}
|
|
2791
2990
|
|
|
2792
2991
|
/** hub hosts 子命令的原始位置参数(保留路径大小写;slug 版本已小写化)。 */
|