@yottameta/yotta-skills-plugin 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,13 +49,19 @@ const agentDirsLib = require('../lib/agent-dirs');
49
49
  const agentDiscoveryLib = require('../lib/agent-discovery');
50
50
  const hubLib = require('../lib/hub');
51
51
  const hubAdoptLib = require('../lib/hub-adopt');
52
+ const hubScanLib = require('../lib/hub-scan');
53
+ const hubViewServerLib = require('../lib/hub-view-server');
52
54
  const { createInstaller, isSafeTarEntry } = require('../lib/install-pipeline');
53
55
  const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
54
56
 
55
57
  const PKG_ROOT = path.join(__dirname, '..');
56
- let VERSION = '0.25.1';
58
+ let VERSION = '0.27.0';
57
59
  try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
58
60
 
61
+ // @generated view-html:start
62
+ const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n\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\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><div class=\"list\" id=\"ovSkills\"></div></div>\n <div class=\"panel\"><h3>最近动作</h3><div class=\"faint\">收编 / 链接 / 解除都写入 Hub 审计。</div><div class=\"list\" id=\"ovAudit\"></div></div>\n </div>\n <div class=\"section panel\">\n <h3>Hub 位置</h3>\n <div class=\"mono\" id=\"ovHubDir\">-</div>\n <div class=\"toolbar section\">\n <button class=\"btn small\" data-goto=\"adopt\">去收编</button>\n <button class=\"btn small\" data-goto=\"links\">去链接与体检</button>\n <button class=\"btn small\" data-goto=\"records\">查看记录</button>\n <button class=\"btn small\" id=\"copyHubDir\">复制路径</button>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-hosts\">\n <div class=\"toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"hostSearch\" placeholder=\"过滤宿主 / 目录\"></label>\n <span class=\"label\" id=\"hostSummary\"></span>\n </div>\n <div class=\"section panel\">\n <table><thead><tr><th>宿主</th><th>技能目录</th><th>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);元技能链接时收敛旧副本(移入回收站,保留 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 id=\"cliGroups\"></div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"mask\"><div class=\"modal\" id=\"modal\"></div></div>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n var TOKEN = (document.querySelector('meta[name=\"yotta-view-token\"]') || {}).content || '';\n var state = { view: 'overview', overview: null, hosts: null, adopt: null, selected: {}, records: null, tab: 'snapshots', hostFilter: '', adoptFilter: '' };\n\n function $(sel) { return document.querySelector(sel); }\n function esc(value) {\n return String(value === null || value === undefined ? '' : value)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n function fmtTime(value) {\n var ms = typeof value === 'number' ? value : Date.parse(value);\n if (!ms || isNaN(ms)) return value ? String(value) : '-';\n var d = new Date(ms);\n function p(n) { return n < 10 ? '0' + n : '' + n; }\n return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());\n }\n function toast(text) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.textContent = text;\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function copyText(text) {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { toast('已复制'); }, function () { fallbackCopy(text); });\n } else fallbackCopy(text);\n }\n function fallbackCopy(text) {\n var ta = document.createElement('textarea');\n ta.value = text; document.body.appendChild(ta); ta.select();\n try { document.execCommand('copy'); toast('已复制'); } catch (e) { toast('复制失败,请手动选择'); }\n ta.remove();\n }\n async function api(path, opts) {\n opts = opts || {};\n var init = { method: opts.method || 'GET', headers: {} };\n if (opts.body !== undefined) {\n init.method = 'POST';\n init.headers['Content-Type'] = 'application/json';\n init.headers['X-Yotta-View-Token'] = TOKEN;\n init.body = JSON.stringify(opts.body);\n }\n var res, data = null;\n try { res = await fetch(path, init); } catch (e) { return { error: '无法连接本地服务:' + e.message }; }\n try { data = await res.json(); } catch (e) { data = null; }\n if (!res.ok) return data && data.error ? data : { error: '请求失败(HTTP ' + res.status + ')' };\n if (!data) return { error: '响应不是合法 JSON' };\n return data;\n }\n\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n var html = '<h3>' + esc(title) + '</h3><div id=\"modalBody\">' + bodyHtml + '</div>' +\n '<div class=\"modal-actions\"><button class=\"btn\" data-modal=\"cancel\">' + esc(opts.cancelText || '取消') + '</button>' +\n (opts.okText === null ? '' : '<button class=\"btn ' + (opts.danger ? 'danger' : 'primary') + '\" data-modal=\"ok\">' + esc(opts.okText || '确认') + '</button>') +\n '</div>';\n $('#modal').innerHTML = html;\n $('#mask').classList.add('open');\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n $('#modal').querySelector('[data-modal=\"cancel\"]').onclick = closeModal;\n if (ok && opts.onOk) {\n ok.onclick = async function () {\n ok.disabled = true;\n try { await opts.onOk(); } finally { ok.disabled = false; }\n };\n }\n }\n function closeModal() { $('#mask').classList.remove('open'); $('#modal').innerHTML = ''; }\n function confirmModal(title, bodyHtml, okText, danger, onOk) {\n openModal(title, bodyHtml, { okText: okText, danger: danger, onOk: onOk });\n }\n $('#mask').addEventListener('click', function (e) { if (e.target === $('#mask')) closeModal(); });\n document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });\n\n var VIEWS = {\n overview: { title: '概览', sub: 'Hub 里有什么、健康吗、最近做了什么', load: loadOverview },\n hosts: { title: '宿主矩阵', sub: '装了哪些宿主、技能目录在哪、Hub 技能链到了谁', load: loadHosts },\n adopt: { title: '收编向导', sub: '有哪些可收编、哪些是重复副本、收编后会发生什么', load: loadAdopt },\n links: { title: '链接与体检', sub: '断链 / 漂移 / 目标缺失在哪、怎么修', load: loadLinks },\n records: { title: '记录与回滚', sub: '安装、收编、链接都做了什么,怎么退回', load: loadRecords },\n route: { title: '路由与编排', sub: '一句话需求该用哪些技能、缺什么(只读)', load: null },\n cli: { title: '高级 CLI', sub: '低频命令速查与复制', load: null }\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 if (VIEWS[name].load) VIEWS[name].load();\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) v.load(); 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 var skills = d.status.skills || [];\n $('#ovSkills').innerHTML = skills.length ? skills.slice(0, 8).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><span class=\"badge ' + (s.status === 'present' ? 'ok' : 'danger') + '\">' + esc(s.status === 'present' ? '已入库' : '缺失') + '</span></div>';\n }).join('') : '<div class=\"empty\">Hub 还是空的。先在「收编向导」把已装技能收编进来。</div>';\n var audit = (d.audit && d.audit.recent) || [];\n $('#ovAudit').innerHTML = audit.length ? audit.slice().reverse().map(function (a) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(a.event || '动作') + ' · ' + esc(a.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(a.at)) + (a.verdict ? ' · ' + esc(a.verdict) : '') + '</div></div></div>';\n }).join('') : '<div class=\"empty\">还没有动作记录。</div>';\n $('#ovHubDir').textContent = d.hubDir;\n $('#copyHubDir').onclick = function () { copyText(d.hubDir); };\n }\n\n async function loadHosts() {\n var d = await api('/api/hosts');\n if (d.error) { toast(d.error); return; }\n state.hosts = d;\n $('#hostSummary').textContent = '目录 ' + d.summary.existing + ' / ' + d.summary.hosts + ' · 已装标记 ' + d.summary.installedMarks + ' · 链接 ' + d.summary.links;\n renderHosts();\n var marks = d.installed || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + (m.hasSkillsDir ? ' · 技能目录:' + esc(m.skillDir || '') : ' · 未发现技能目录') + '</div></div><span class=\"badge ' + (m.hasSkillsDir ? 'ok' : 'info') + '\">' + esc(m.hasSkillsDir ? '可链接' : '仅标记') + '</span></div>';\n }).join('') : '<div class=\"empty\">没有检测到额外宿主标记。</div>';\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var rows = (d.hosts || []).filter(function (h) {\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n $('#hostRows').innerHTML = rows.map(function (h) {\n var status = !h.exists ? '<span class=\"badge\">未创建</span>' : (h.brokenLinks ? '<span class=\"badge danger\">有异常</span>' : '<span class=\"badge ok\">可用</span>');\n 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>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + status + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy]');\n if (b) copyText(b.dataset.copy);\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + '</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\"><button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button></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 d = await api('/api/route?request=' + encodeURIComponent(request));\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 var CLI_GROUPS = [\n { name: 'Hub 发现与安装', items: [\n { cmd: 'yotta-skills hub hosts', why: '发现本机宿主与技能目录' },\n { cmd: 'yotta-skills hub install', why: '把元阁家族装进 Hub 真源' },\n { cmd: 'yotta-skills hub adopt --scan', why: '只读预演收编候选与冲突' },\n { cmd: 'yotta-skills hub adopt --apply', why: '执行收编(默认复制保真)' },\n ] },\n { name: '链接与体检', items: [\n { cmd: 'yotta-skills hub link --all', why: '把 Hub 技能链接到全部宿主' },\n { cmd: 'yotta-skills hub link --agent codex', why: '只链接到一个宿主' },\n { cmd: 'yotta-skills hub unlink --all', why: '只删链接,不动 Hub 真源' },\n { cmd: 'yotta-skills hub status', why: '查看技能 / 来源 / 链接' },\n { cmd: 'yotta-skills hub doctor', why: '检查断链 / 缺失 / slug 不一致' },\n ] },\n { name: '安装与更新', items: [\n { cmd: 'yotta-skills update --installed-only --dir <技能目录>', why: '只维护已装元技能' },\n { cmd: 'yotta-skills update --check', why: '只读检查更新' },\n { cmd: 'yotta-skills rollback --list', why: '查看可回滚快照' },\n { cmd: 'yotta-skills rollback --slug <技能> --dir <技能目录>', why: '回滚单个技能' },\n ] },\n { name: '路由与使用', items: [\n { cmd: 'yotta-skills --route \"帮我做发布前质量检查\"', why: '静态编排路由 + 动态路由块' },\n { cmd: 'yotta-skills --list', why: '列出全家技能与版本' },\n { cmd: 'yotta-skills --inventory --json', why: '盘点本机已装技能' },\n ] },\n ];\n $('#cliGroups').innerHTML = CLI_GROUPS.map(function (g) {\n return '<div class=\"panel cmd-group\"><h3>' + esc(g.name) + '</h3>' + g.items.map(function (item) {\n return '<div class=\"cmd\"><code>' + esc(item.cmd) + '</code><span class=\"why\">' + esc(item.why) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(item.cmd) + '\">复制</button></div>';\n }).join('') + '</div>';\n }).join('');\n document.addEventListener('click', function (e) {\n var b = e.target.closest('#cliGroups [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 loadOverview();\n })();\n})();\n</script>\n</body>\n</html>\n";
63
+ // @generated view-html:end
64
+
59
65
  function loadManifest() {
60
66
  const file = process.env.YOTTA_SKILLS_MANIFEST || path.join(PKG_ROOT, 'skills.json');
61
67
  let data;
@@ -156,6 +162,7 @@ function parseArgs(argv) {
156
162
  hub: null, all: false, inPlace: false, allowUnverified: false,
157
163
  from: null, include: [], as: null, skillsCli: null,
158
164
  scan: false, apply: false,
165
+ port: null,
159
166
  };
160
167
  const positionals = [];
161
168
  for (let i = 0; i < argv.length; i++) {
@@ -221,6 +228,14 @@ function parseArgs(argv) {
221
228
  }
222
229
  else if (a === '--as') opts.as = take('--as').toLowerCase();
223
230
  else if (a === '--skills-cli') opts.skillsCli = take('--skills-cli');
231
+ else if (a === '--port') {
232
+ const raw = take('--port');
233
+ const value = Number(raw);
234
+ if (!Number.isInteger(value) || value < 0 || value > 65535) {
235
+ die('--port 端口非法: ' + raw, 2, '请提供 0-65535 之间的整数;例如 --port 8789。');
236
+ }
237
+ opts.port = value;
238
+ }
224
239
  else if (a === '--host') opts.host = take('--host').toLowerCase();
225
240
  else if (a === '--event') opts.event = take('--event').toLowerCase();
226
241
  else if (a === '--manifest') opts.manifest = take('--manifest');
@@ -240,7 +255,7 @@ function parseArgs(argv) {
240
255
  continue;
241
256
  }
242
257
  if (p === 'install' || p === 'update' || p === 'doctor' || p === 'rollback' || p === 'hook'
243
- || p === 'hub'
258
+ || p === 'hub' || p === 'view'
244
259
  || p === 'usage' || p === 'decide-memory') {
245
260
  if (opts.command && opts.command !== p) die('命令冲突:' + opts.command + ' 与 ' + p);
246
261
  opts.command = p;
@@ -261,6 +276,9 @@ function parseArgs(argv) {
261
276
  if (opts.installedOnly && opts.command !== 'update') {
262
277
  die('--installed-only 只能与 update 一起使用', 2, '标准配方:yotta-skills update --installed-only --dir <技能目录>。');
263
278
  }
279
+ if (opts.port !== null && opts.command !== 'view') {
280
+ die('--port 只能与 view 一起使用', 2, '示例:yotta-skills view --port 8789。');
281
+ }
264
282
  if ((opts.all || opts.inPlace || opts.allowUnverified || opts.from || opts.include.length || opts.as || opts.scan || opts.apply)
265
283
  && opts.command !== 'hub') {
266
284
  die('--all / --in-place / --allow-unverified / --from / --include / --as / --scan / --apply 只能与 hub 命令一起使用', 2,
@@ -317,6 +335,38 @@ function selectSkills(opts) {
317
335
  return list;
318
336
  }
319
337
 
338
+ /** Hub 范围选择:全家清单 27 + 特殊家族 5(特殊家族版本跟随各自 npm latest)。 */
339
+ function findHubSkill(slug) {
340
+ return findSkill(slug) || hubLib.HUB_FAMILY_EXTRAS.find((s) =>
341
+ s.slug === slug || s.pkg === slug || s.pkg.replace('@yottameta/', '') === slug);
342
+ }
343
+
344
+ function selectHubSkills(opts) {
345
+ const picked = [];
346
+ const seen = new Set();
347
+ const push = (s) => {
348
+ if (!seen.has(s.slug)) {
349
+ seen.add(s.slug);
350
+ picked.push(s);
351
+ }
352
+ };
353
+ for (const slug of opts.skills) {
354
+ const s = findHubSkill(slug);
355
+ if (!s) die('未知技能: ' + slug + '(可用: yotta-skills --list)', 2, '请先运行 --list 查看技能名,或检查拼写。');
356
+ push(s);
357
+ }
358
+ for (const slug of opts.only || []) {
359
+ const s = findHubSkill(slug);
360
+ if (!s) die('未知技能(--only): ' + slug + '(可用: yotta-skills --list)', 2, '请先运行 --list 查看技能名,或检查拼写。');
361
+ push(s);
362
+ }
363
+ let list = picked.length > 0 ? picked : MANIFEST.slice().concat(hubLib.HUB_FAMILY_EXTRAS);
364
+ if (opts.domain) {
365
+ list = list.filter((s) => s.domain === opts.domain);
366
+ }
367
+ return list;
368
+ }
369
+
320
370
  function readInstalledVersion(dir) {
321
371
  const f = path.join(dir, 'SKILL.md');
322
372
  try {
@@ -608,7 +658,7 @@ function detectProjectDir() {
608
658
  function findVerifyEngine(dest, opts) {
609
659
  // v0.19.13:不再用注册表(身份来自被扫描技能自己的 frontmatter)发现校验器,
610
660
  // 只认安装管线写入的受信记录(路径 + 摘要双绑定)。
611
- return gateLib.findVerifier({ dest, opts, trustedVerifier: trustedVerifierLib.loadRecord() });
661
+ return hubScanLib.findVerifyEngine(dest, opts);
612
662
  }
613
663
 
614
664
  /** 安装 / 更新元信成功后写入受信记录(身份 + 摘要),后续门禁只认这份记录。 */
@@ -629,29 +679,11 @@ function recordTrustedVerifier(dest) {
629
679
  }
630
680
 
631
681
  function findPython(opts) {
632
- const cands = [];
633
- if (opts.python) cands.push(opts.python);
634
- if (process.env.YOTTA_SKILLS_PYTHON) cands.push(process.env.YOTTA_SKILLS_PYTHON);
635
- cands.push('python3', 'python');
636
- if (process.platform === 'win32') cands.push('py');
637
- for (const c of cands) {
638
- try {
639
- const r = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 10000 });
640
- if (r.status === 0) return c;
641
- } catch (_) { /* next */ }
642
- }
643
- return null;
682
+ return hubScanLib.findPython(opts);
644
683
  }
645
684
 
646
685
  function runScan(engine, skillDir, opts) {
647
- const python = findPython(opts || {});
648
- if (!python) {
649
- return {
650
- ok: false,
651
- error: depsLib.describe('python', { command: currentCommand(), missing: true }),
652
- };
653
- }
654
- return gateLib.runVerifier(engine, skillDir, { python, spawnSync });
686
+ return hubScanLib.runScan(engine, skillDir, opts);
655
687
  }
656
688
 
657
689
  function scanTarget(engine, skillDir, context) {
@@ -862,14 +894,17 @@ const installOne = createInstaller({
862
894
  });
863
895
 
864
896
  function runInstall(opts, dest) {
865
- const skills = selectSkills(opts);
897
+ const skills = opts.hubScope ? selectHubSkills(opts) : selectSkills(opts);
866
898
  if (skills.length === 0) {
867
899
  out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
868
900
  process.exitCode = 2;
869
901
  return { failed: 1, exitCode: 2 };
870
902
  }
871
903
  out('yotta-skills(元阁)v' + VERSION + ' —— 安装 ' + skills.length + ' 个技能 -> ' + dest);
872
- out('版本策略: ' + (opts.pin ? 'pin(锁死清单精确版本,默认)' : 'range(' + skillRange(skills[0]) + ',跟随最新 patch)'));
904
+ const sample = skills.find((s) => s.version !== 'latest') || null;
905
+ out('版本策略: ' + (opts.pin
906
+ ? 'pin(锁死清单精确版本,默认)' + (skills.some((s) => s.version === 'latest') ? ';特殊家族跟随各自 npm latest' : '')
907
+ : 'range(' + (sample ? skillRange(sample) : 'latest') + ',跟随最新 patch)'));
873
908
  const results = [];
874
909
  let failed = 0;
875
910
  let exitCode = 0;
@@ -899,7 +934,7 @@ function runInstall(opts, dest) {
899
934
  }
900
935
 
901
936
  function runUpdate(opts, dest) {
902
- let skills = selectSkills(opts);
937
+ let skills = opts.hubScope ? selectHubSkills(opts) : selectSkills(opts);
903
938
  out('yotta-skills(元阁)v' + VERSION + ' —— 增量更新 -> ' + dest);
904
939
  if (opts.installedOnly) {
905
940
  const candidates = skills.length;
@@ -1323,15 +1358,18 @@ function printHelp() {
1323
1358
  out(' yotta-skills hook bind --manifest <file> 注册 hook 声明(幂等)');
1324
1359
  out(' yotta-skills hook unbind <binding-id> 反注册 hook 声明');
1325
1360
  out(' yotta-skills hub hosts 发现本机已装智能体与技能目录(不读元忆)');
1326
- out(' yotta-skills hub install [skill...] 把元技能装进本机 Hub(单点真源)');
1361
+ out(' yotta-skills hub install [skill...] 把元技能装进本机 Hub(清单 27 + 特殊家族 5;单点真源)');
1362
+ out(' yotta-skills hub update 更新 Hub 内的元技能(非元技能不参与,用户自行处理)');
1327
1363
  out(' yotta-skills hub adopt --scan 只读预演:扫描各宿主现有技能,输出收编候选与冲突');
1328
1364
  out(' yotta-skills hub adopt --apply 把选中技能复制进 Hub(默认保留原目录;--in-place 原地登记)');
1329
1365
  out(' yotta-skills hub refresh <slug> --from <path> 手动同步非元阁技能(无统一更新源)');
1330
- out(' yotta-skills hub link --all 把 Hub 技能链接到全部已发现宿主(junction / symlink)');
1366
+ out(' yotta-skills hub link --all 把 Hub 技能链接到全部已发现宿主;元技能链接时收敛旧副本(回收站 7 天)');
1331
1367
  out(' yotta-skills hub link --agent <id> 只链接到一个宿主(未知宿主用 --dir)');
1332
1368
  out(' yotta-skills hub unlink --all 只删除链接,不动 Hub 真源(fail-closed)');
1333
1369
  out(' yotta-skills hub status 查看 Hub 技能 / 来源 / 链接 / 宿主');
1334
- out(' yotta-skills hub doctor 检查断链 / 目标缺失 / slug 不一致 / 目录权限');
1370
+ out(' yotta-skills hub doctor 检查断链 / 目标缺失 / slug 不一致 / 单一真源(只读)/ 目录权限');
1371
+ out(' yotta-skills view 启动本机技能枢纽面板(127.0.0.1:8789)');
1372
+ out(' yotta-skills view --port <端口> 指定面板端口(仅本机监听)');
1335
1373
  out('');
1336
1374
  out('选项:');
1337
1375
  out(' --agent <name> 智能体键名(--list 可查看;未知智能体请用 --dir)');
@@ -1363,6 +1401,7 @@ function printHelp() {
1363
1401
  out(' --explain decide-memory 文本报告追加信号明细');
1364
1402
  out(' --promote decide-memory 只写本地建议文件(不写元忆)');
1365
1403
  out(' --hub <path> Hub 目录(默认 ~/.yottaskills/hub;YOTTA_SKILLS_HUB 覆盖)');
1404
+ out(' --port <n> view 面板端口(默认 8789;仅 127.0.0.1 监听)');
1366
1405
  out(' --all hub link / unlink 时作用于全部已发现宿主');
1367
1406
  out(' --scan hub adopt 只读预演(默认行为)');
1368
1407
  out(' --apply hub adopt 执行收编');
@@ -1943,31 +1982,11 @@ function printHubStatus(payload, discovery, json) {
1943
1982
  }
1944
1983
 
1945
1984
  function hubScanEngine(hubDir, discovery, opts) {
1946
- const direct = findVerifyEngine(hubDir, opts);
1947
- if (direct) return direct;
1948
- for (const host of discovery.hosts) {
1949
- if (!host.exists) continue;
1950
- const engine = findVerifyEngine(host.dir, opts);
1951
- if (engine) return engine;
1952
- }
1953
- return null;
1985
+ return hubScanLib.scanEngineForHub(hubDir, discovery, opts);
1954
1986
  }
1955
1987
 
1956
1988
  function hubScanSkill(hubDir, discovery, opts, skillDir) {
1957
- if (opts.skipScan) {
1958
- return { ok: true, verdict: 'explicit-unverified', counts: null, block: false };
1959
- }
1960
- const engine = hubScanEngine(hubDir, discovery, opts);
1961
- if (!engine) {
1962
- return {
1963
- ok: false,
1964
- error: '未找到元信扫描引擎(可先安装 yotta-verify,或用 --allow-unverified 显式降级)',
1965
- };
1966
- }
1967
- const scan = runScan(engine, skillDir, opts);
1968
- if (!scan.ok) return scan;
1969
- const verdict = gateLib.evaluateVerdict(scan.verdict);
1970
- return { ok: true, verdict: scan.verdict, counts: scan.counts, block: verdict.block };
1989
+ return hubScanLib.hubScanSkill(hubDir, discovery, opts, skillDir);
1971
1990
  }
1972
1991
 
1973
1992
  function printAdoptScan(payload, json) {
@@ -1988,6 +2007,9 @@ function printAdoptScan(payload, json) {
1988
2007
  out(' 冲突副本: ' + item.variants.map((variant) =>
1989
2008
  (variant.host || '?') + ' v' + (variant.version || '?')).join(', '));
1990
2009
  }
2010
+ if (item.standardWarnings && item.standardWarnings.length) {
2011
+ out(' 标准告警(不影响使用则不处理): ' + item.standardWarnings.join(';'));
2012
+ }
1991
2013
  }
1992
2014
  if (payload.candidates.length === 0) out(' (没有发现可收编技能)');
1993
2015
  }
@@ -1998,7 +2020,9 @@ function printAdoptApply(payload, json) {
1998
2020
  return;
1999
2021
  }
2000
2022
  out('yotta-skills(元阁)v' + VERSION + ' —— Hub 收编');
2001
- out('Hub: ' + payload.hubDir);
2023
+ out('目标 Hub: ' + payload.hubDir);
2024
+ if (payload.scope) out('范围: ' + payload.scope);
2025
+ if (payload.skipScan) out('⚠ --skip-scan:已跳过元信装前扫描复核,请确认来源可信。');
2002
2026
  for (const item of payload.results) {
2003
2027
  const mark = item.status === 'imported' ? '✔' : item.status === 'skip' ? '·' : item.status === 'conflict' ? '△' : '✘';
2004
2028
  out(' ' + mark + ' ' + item.slug.padEnd(26) + item.note);
@@ -2037,10 +2061,18 @@ function runHub(opts) {
2037
2061
 
2038
2062
  if (action === 'install' || action === 'update') {
2039
2063
  out('Hub: ' + hubDir);
2040
- const result = action === 'install' ? runInstall(opts, hubDir) : runUpdate(opts, hubDir);
2064
+ out('范围: 全家清单 ' + MANIFEST.length + ' + 特殊家族 ' + hubLib.HUB_FAMILY_EXTRAS.length +
2065
+ '(特殊家族跟随各自 npm latest;非元技能不参与更新)');
2066
+ const hubOpts = { ...opts, hubScope: true };
2067
+ const result = action === 'install' ? runInstall(hubOpts, hubDir) : runUpdate(hubOpts, hubDir);
2041
2068
  const synced = hubLib.syncHubState(hubDir, { manifest: MANIFEST });
2042
2069
  const count = Object.values(synced.state.skills).filter((item) => item.status === 'present').length;
2070
+ const external = Object.values(synced.state.skills)
2071
+ .filter((item) => item.origin === 'external' && item.status === 'present').length;
2043
2072
  out('');
2073
+ if (action === 'update' && external > 0) {
2074
+ out('非元技能 ' + external + ' 个不参与更新(来源无统一安装源,用户自行处理)。');
2075
+ }
2044
2076
  out('Hub 台账已更新: ' + count + ' 个技能;标准 ' + hubLib.STANDARD_ID);
2045
2077
  if (result.failed > 0) process.exitCode = result.exitCode || 1;
2046
2078
  return;
@@ -2050,7 +2082,7 @@ function runHub(opts) {
2050
2082
  if (opts.as) {
2051
2083
  die('hub adopt --as 暂未启用', 2, '先收编原 slug;改名 / 冲突改写放到后续里程碑,避免静默改技能身份。');
2052
2084
  }
2053
- const scan = hubAdoptLib.scanCandidates({ hubDir, discovery });
2085
+ const scan = hubAdoptLib.scanCandidates({ hubDir, discovery, manifest: MANIFEST });
2054
2086
  if (!opts.apply) {
2055
2087
  printAdoptScan(scan, opts.json);
2056
2088
  return;
@@ -2068,6 +2100,10 @@ function runHub(opts) {
2068
2100
  env: process.env,
2069
2101
  scan: (skillDir) => hubScanSkill(hubDir, discovery, opts, skillDir),
2070
2102
  });
2103
+ payload.scope = opts.include && opts.include.length
2104
+ ? '指定 ' + opts.include.length + ' 个技能(--include)'
2105
+ : '全部候选(本机已发现宿主)';
2106
+ payload.skipScan = Boolean(opts.skipScan);
2071
2107
  printAdoptApply(payload, opts.json);
2072
2108
  if (payload.results.some((item) => !['imported', 'skip', 'conflict'].includes(item.status))) process.exitCode = 1;
2073
2109
  return;
@@ -2112,16 +2148,24 @@ function runHub(opts) {
2112
2148
  slugs: opts.skills,
2113
2149
  force: opts.force,
2114
2150
  dryRun: opts.dryRun,
2151
+ manifest: MANIFEST,
2115
2152
  });
2116
2153
  targetPayloads.push({ dir: target.dir, label: target.label, results: result.results });
2117
2154
  if (!opts.json) {
2118
2155
  out('');
2119
2156
  out('目标: ' + target.dir + '(' + target.label + ')');
2120
2157
  for (const item of result.results) {
2121
- const mark = item.status === 'linked' ? '✔' : item.status === 'conflict' ? '△' : item.status === 'error' ? '✘' : '·';
2158
+ const mark = item.status === 'linked' ? '✔'
2159
+ : item.status === 'error' ? '✘'
2160
+ : item.status === 'missing' ? '·' : '△';
2122
2161
  out(' ' + mark + ' ' + item.slug.padEnd(26) + item.note);
2123
2162
  if (item.status === 'error') failed++;
2124
2163
  }
2164
+ const movedCount = result.results.reduce((n, item) => n + ((item.moved && item.moved.length) || 0), 0);
2165
+ if (movedCount > 0) {
2166
+ out(' 收敛: 移动 ' + movedCount + ' 份旧副本 -> ' + (result.trashRoot || '') +
2167
+ '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
2168
+ }
2125
2169
  } else {
2126
2170
  failed += result.results.filter((item) => item.status === 'error').length;
2127
2171
  }
@@ -2172,6 +2216,7 @@ function runHub(opts) {
2172
2216
  manifest: MANIFEST,
2173
2217
  homeDir: os.homedir(),
2174
2218
  env: process.env,
2219
+ discovery,
2175
2220
  });
2176
2221
  printHubDoctor(payload, opts.json);
2177
2222
  if (!payload.ok) process.exitCode = 1;
@@ -2182,6 +2227,45 @@ function runHub(opts) {
2182
2227
  '支持 install / update / adopt / refresh / link / unlink / status / hosts / doctor。');
2183
2228
  }
2184
2229
 
2230
+ // ── view:本机技能枢纽面板(127.0.0.1,只读 API + 预览确认动作) ───────────
2231
+ function runView(opts) {
2232
+ const home = os.homedir();
2233
+ const hubDir = hubLib.resolveHubDir(opts);
2234
+ const port = opts.port === null || opts.port === undefined ? hubViewServerLib.DEFAULT_PORT : opts.port;
2235
+ const view = hubViewServerLib.createHubViewServer({
2236
+ hubDir,
2237
+ homeDir: home,
2238
+ env: process.env,
2239
+ manifest: MANIFEST,
2240
+ version: VERSION,
2241
+ port,
2242
+ html: VIEW_HTML,
2243
+ python: opts.python,
2244
+ verify: opts.verify,
2245
+ scanSkill: (skillDir, found, scanOpts) => hubScanSkill(hubDir, found, {
2246
+ python: opts.python,
2247
+ verify: opts.verify,
2248
+ skipScan: Boolean(scanOpts && scanOpts.skipScan),
2249
+ }, skillDir),
2250
+ onError: (error) => {
2251
+ if (error && error.code === 'EADDRINUSE') {
2252
+ die('端口 ' + port + ' 已被占用(yotta-skills view 未启动)', 2,
2253
+ '换一个端口:yotta-skills view --port ' + (port + 1 < 65535 ? port + 1 : 8789) + ';或先关闭占用该端口的进程。');
2254
+ }
2255
+ die('yotta-skills view 启动失败: ' + (error && error.message ? error.message : String(error)), 2,
2256
+ '检查端口占用与目录权限后重试。');
2257
+ },
2258
+ });
2259
+ view.listen(port, '127.0.0.1', () => {
2260
+ const address = view.server.address();
2261
+ const actual = address && typeof address === 'object' ? address.port : port;
2262
+ out('yotta-skills(元阁)v' + VERSION + ' —— 技能枢纽面板已启动');
2263
+ out('URL: http://127.0.0.1:' + actual + '(仅本机;按 Ctrl+C 停止)');
2264
+ out('Hub: ' + hubDir + (fs.existsSync(hubDir) ? '' : '(尚未初始化:面板会给出引导)'));
2265
+ out('边界: 零遥测 / 零远程资源;写操作需页面会话令牌,破坏性动作需二次确认。');
2266
+ });
2267
+ }
2268
+
2185
2269
  // ── main ───────────────────────────────────────────────────────────────────
2186
2270
  function main() {
2187
2271
  const node = depsLib.nodeCheck();
@@ -2210,6 +2294,10 @@ function main() {
2210
2294
  runHook(opts);
2211
2295
  return;
2212
2296
  }
2297
+ if (command === 'view') {
2298
+ runView(opts);
2299
+ return;
2300
+ }
2213
2301
  if (command === 'hub') {
2214
2302
  runHub(opts);
2215
2303
  return;
@@ -61,7 +61,7 @@ const AGENT_DIRS = {
61
61
  opencode: { label: 'OpenCode', dirs: ['.config/opencode/skills', '.agents/skills'] },
62
62
  openhands: { label: 'OpenHands', dirs: ['.openhands/skills'] },
63
63
  ona: { label: 'Ona', dirs: ['.ona/skills'] },
64
- openclaw: { label: 'OpenClaw / QClaw', dirs: ['.openclaw/skills'] },
64
+ openclaw: { label: 'OpenClaw', dirs: ['.openclaw/skills'] },
65
65
  pi: { label: 'Pi', dirs: ['.agents/skills'] },
66
66
  posit: { label: 'Posit Assistant', dirs: ['.posit/assistant/skills'] },
67
67
  qoder: { label: 'Qoder', dirs: ['.qoder/skills'] },
@@ -34,6 +34,7 @@ function scanCandidates(options) {
34
34
  const hubDir = path.resolve(opts.hubDir);
35
35
  const discovery = opts.discovery || { hosts: [] };
36
36
  const existing = new Map(hubLib.scanHubSkills(hubDir).map((item) => [item.slug, item]));
37
+ const family = hubLib.familySlugSet(opts.manifest);
37
38
  const groups = new Map();
38
39
 
39
40
  for (const host of discovery.hosts) {
@@ -77,6 +78,7 @@ function scanCandidates(options) {
77
78
  variants: group.variants,
78
79
  inHub: group.inHub,
79
80
  conflict: hashes.size > 1 || versions.size > 1,
81
+ standardWarnings: externalStandardWarnings(group.slug, selected, family),
80
82
  });
81
83
  }
82
84
  candidates.sort((a, b) => a.slug.localeCompare(b.slug));
@@ -92,6 +94,20 @@ function scanCandidates(options) {
92
94
  };
93
95
  }
94
96
 
97
+ /**
98
+ * 非元技能标准合规只读告警(不影响使用则不处理、不改源文件)。
99
+ * 家族技能不告警(同类问题已由生成器批次修复并受门禁保护)。
100
+ */
101
+ function externalStandardWarnings(slug, selected, family) {
102
+ if (!selected || !selected.dir || family.has(slug)) return [];
103
+ try {
104
+ const text = fs.readFileSync(path.join(selected.dir, 'SKILL.md'), 'utf8');
105
+ return scanLib.strictFrontmatterWarnings(text);
106
+ } catch (_) {
107
+ return [];
108
+ }
109
+ }
110
+
95
111
  function includeMatch(slug, include) {
96
112
  if (!Array.isArray(include) || include.length === 0) return true;
97
113
  return include.includes(slug);