@yottameta/yotta-skills-plugin 0.28.2 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +64 -4
- package/skills/yotta-skills/assets/view.html +214 -50
- package/skills/yotta-skills/bin/yotta-skills.js +580 -139
- package/skills/yotta-skills/lib/agent-discovery.js +99 -7
- package/skills/yotta-skills/lib/cli-help.js +343 -0
- package/skills/yotta-skills/lib/host-purge.js +167 -0
- package/skills/yotta-skills/lib/hosts-registry.js +197 -0
- package/skills/yotta-skills/lib/hub-view-server.js +175 -1
- package/skills/yotta-skills/lib/hub.js +288 -0
- package/skills/yotta-skills/lib/install-pipeline.js +19 -0
- package/skills/yotta-skills/lib/self-install.js +224 -0
- package/skills/yotta-skills/references/hub.md +66 -2
- package/skills/yotta-skills/references/skill-list.md +2 -2
- package/skills/yotta-skills/scan-policy.json +3 -3
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
- package/skills/yotta-skills/skills.json +1 -1
|
@@ -49,17 +49,20 @@ const agentDirsLib = require('../lib/agent-dirs');
|
|
|
49
49
|
const agentDiscoveryLib = require('../lib/agent-discovery');
|
|
50
50
|
const hubLib = require('../lib/hub');
|
|
51
51
|
const hubAdoptLib = require('../lib/hub-adopt');
|
|
52
|
+
const selfInstallLib = require('../lib/self-install');
|
|
53
|
+
const hostsRegistryLib = require('../lib/hosts-registry');
|
|
54
|
+
const hostPurgeLib = require('../lib/host-purge');
|
|
52
55
|
const hubScanLib = require('../lib/hub-scan');
|
|
53
56
|
const hubViewServerLib = require('../lib/hub-view-server');
|
|
54
57
|
const { createInstaller, isSafeTarEntry } = require('../lib/install-pipeline');
|
|
55
58
|
const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
|
|
56
59
|
|
|
57
60
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
58
|
-
let VERSION = '0.
|
|
61
|
+
let VERSION = '0.29.0';
|
|
59
62
|
try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
|
|
60
63
|
|
|
61
64
|
// @generated view-html:start
|
|
62
|
-
const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .loadbar{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px;padding:10px 13px;border:1px dashed var(--line2);border-radius:8px;margin:0 0 14px;background:var(--panel)}\n .spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--acc);animation:spin .8s linear infinite;flex:none}\n @keyframes spin{to{transform:rotate(360deg)}}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n\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><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>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);默认范围 = 已核实宿主,自动发现目录需在 CLI 用 --include-discovered 显式纳入,锁 / 数据桥接目录永不链接;元技能链接时收敛旧副本(移入回收站,保留 7 天可恢复)。</div>\n <div class=\"list\" id=\"linkTargets\"></div>\n </div>\n <div class=\"section panel\">\n <h3>当前链接</h3>\n <table><thead><tr><th>技能</th><th>宿主</th><th>目标</th><th>状态</th><th></th></tr></thead><tbody id=\"linkRows\"></tbody></table>\n <div class=\"empty\" id=\"linkEmpty\" hidden>还没有任何链接。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-records\">\n <div class=\"toolbar\">\n <button class=\"btn small\" data-tab=\"snapshots\">回滚快照</button>\n <button class=\"btn small\" data-tab=\"evidence\">安装 / 回滚证据</button>\n <button class=\"btn small\" data-tab=\"audit\">Hub 审计</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"recordsInfo\"></span>\n </div>\n <div class=\"section panel\" id=\"recSnapshots\">\n <table><thead><tr><th>技能</th><th>版本</th><th>时间</th><th>来源目录</th><th>状态</th><th></th></tr></thead><tbody id=\"snapshotRows\"></tbody></table>\n <div class=\"empty\" id=\"snapshotEmpty\" hidden>没有安装快照。快照由 install / update 写入。</div>\n </div>\n <div class=\"section panel\" id=\"recEvidence\" hidden>\n <div class=\"list\" id=\"evidenceRows\"></div>\n <div class=\"empty\" id=\"evidenceEmpty\" hidden>没有证据记录。</div>\n </div>\n <div class=\"section panel\" id=\"recAudit\" hidden>\n <div class=\"list\" id=\"auditRows\"></div>\n <div class=\"empty\" id=\"auditEmpty\" hidden>没有 Hub 审计记录。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-route\">\n <div class=\"panel\">\n <h3>一句话需求 → 技能组合</h3>\n <div class=\"faint\">只读调用静态 playbook(7 组)+ 动态路由块;安装建议只给命令,不在网页执行。</div>\n <div class=\"toolbar section\">\n <label class=\"search\" style=\"flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"routeInput\" placeholder=\"例如:帮我做发布前质量检查\"></label>\n <button class=\"btn primary\" id=\"routeBtn\">给出组合</button>\n </div>\n </div>\n <div id=\"routeResult\" class=\"section\"></div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"banner\">\n <svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>\n <div><div class=\"banner-title\">高级 CLI</div><div class=\"banner-body\">低频 / 长尾命令在这里复制到终端执行;网页不代替终端运行安装与更新。</div></div>\n </div>\n <div 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, '&').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: '低频命令速查与复制', 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 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.verified + ' · 自动发现 ' + d.summary.discovered + ' · 桥接 ' + d.summary.bridge + ')· 已装标记 ' + d.summary.installedMarks + ' · 链接 ' + d.summary.links;\n renderHosts();\n var marks = d.installed || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + (m.hasSkillsDir ? ' · 技能目录:' + esc(m.skillDir || '') : ' · 未发现技能目录') + '</div></div><span class=\"badge ' + (m.hasSkillsDir ? 'ok' : 'info') + '\">' + esc(m.hasSkillsDir ? '可链接' : '仅标记') + '</span></div>';\n }).join('') : '<div class=\"empty\">没有检测到额外宿主标记。</div>';\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var rows = (d.hosts || []).filter(function (h) {\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n $('#hostRows').innerHTML = rows.map(function (h) {\n var status = !h.exists ? '<span class=\"badge\">未创建</span>' : (h.brokenLinks ? '<span class=\"badge danger\">有异常</span>' : '<span class=\"badge ok\">可用</span>');\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n return '<tr><td><div class=\"cell-main\">' + esc(h.label) + '</div><div class=\"cell-sub\">' + esc(h.agentId || (h.known ? '已知宿主' : h.detection || '自动发现')) + '</div></td>' +\n '<td><div class=\"mono\">' + esc(h.dir) + '</div><button class=\"btn small\" data-copy=\"' + esc(h.dir) + '\">复制</button></td>' +\n '<td>' + scopeBadge + '</td>' +\n '<td>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + status + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy]');\n if (b) copyText(b.dataset.copy);\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链接</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n var action = h.bridgeOnly ? '' : '<button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button>';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + ' ' + scopeBadge + '</div><div class=\"row-meta mono\">' + esc(h.dir) + '</div><div class=\"row-meta\">目录技能 ' + esc(h.skillCount || 0) + ' · 指向 Hub ' + esc(h.hubLinks || 0) + (h.brokenLinks ? ' · 异常 ' + h.brokenLinks : '') + '</div></div><div class=\"row-actions\">' + action + '</div></div>';\n }).join('') : '<div class=\"empty\">尚未发现可用的宿主技能目录。</div>';\n var rows = links.links || [];\n $('#linkEmpty').hidden = rows.length > 0;\n $('#linkRows').innerHTML = rows.map(function (l) {\n var tone = l.status === 'ok' ? 'ok' : l.status === 'broken' ? 'danger' : 'warn';\n return '<tr><td><div class=\"cell-main\">' + esc(l.slug) + '</div><div class=\"cell-sub\">' + esc(l.label || l.agent || '-') + '</div></td>' +\n '<td class=\"mono\">' + esc(l.dir) + '</td><td class=\"mono\">' + esc(l.target) + '</td>' +\n '<td><span class=\"badge ' + tone + '\">' + esc(l.status) + '</span></td>' +\n '<td><button class=\"btn small danger\" data-unlink=\"' + esc(l.target) + '\" data-dir=\"' + esc(l.dir) + '\" data-slug=\"' + esc(l.slug) + '\">解除</button></td></tr>';\n }).join('');\n }\n $('#linkTargets').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-link-plan]');\n if (!b) return;\n var dir = b.dataset.linkPlan;\n var plan = await api('/api/links/plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var would = plan.results.filter(function (r) { return r.status === 'would-link'; });\n var converge = plan.results.filter(function (r) { return r.status === 'would-converge'; });\n var skipped = plan.results.filter(function (r) { return r.status === 'skipped'; });\n var conflicts = plan.results.filter(function (r) { return r.status === 'conflict'; });\n var convergeMoves = converge.reduce(function (n, r) { return n + ((r.moves && r.moves.length) || 0); }, 0);\n var linkable = would.length + converge.length;\n var body = '<p class=\"label\">目标:' + esc(plan.label || '') + ' · <span class=\"mono\">' + esc(dir) + '</span></p>' +\n '<div class=\"result\"><span class=\"badge ok\">将建立</span><b>' + linkable + '</b><span>个链接</span></div>' +\n (converge.length ? '<div class=\"result\"><span class=\"badge warn\">将收敛</span><b>' + convergeMoves + '</b><span>份旧副本 → 回收站(保留 7 天,可恢复):' + esc(converge.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (skipped.length ? '<div class=\"result\"><span class=\"badge warn\">跳过</span><b>' + skipped.length + '</b><span>项(Hub 版本旧于宿主 / 版本无法解析):' + esc(skipped.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (conflicts.length ? '<div class=\"result\"><span class=\"badge warn\">跳过冲突</span><b>' + conflicts.length + '</b><span>个同名目录 / 链接(默认不覆盖)</span></div>' : '') +\n (linkable ? '<details><summary>查看明细</summary><div class=\"variants\">' + would.concat(converge).map(function (r) { return '<div class=\"variant\"><span class=\"mono\">' + esc(r.slug) + '</span></div>'; }).join('') + '</div></details>' : '<p class=\"faint\">没有可建立的链接。</p>');\n openModal('链接预览', body, {\n okText: linkable ? '执行链接' : null,\n onOk: async function () {\n var r = await api('/api/links/apply', { body: { targetDir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var done = (r.results || []).filter(function (x) { return x.status === 'linked'; }).length;\n var moved = (r.results || []).reduce(function (n, x) { return n + ((x.moved && x.moved.length) || 0); }, 0);\n toast('已建立 ' + done + ' 个链接' + (moved ? ';收敛 ' + moved + ' 份旧副本(回收站 7 天)' : ''));\n loadLinks();\n },\n });\n });\n $('#linkRows').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-unlink]');\n if (!b) return;\n var target = b.dataset.unlink;\n var dir = b.dataset.dir;\n var slug = b.dataset.slug;\n confirmModal('解除链接 ' + slug + '?', '<p class=\"label\">只删除宿主目录中的链接,Hub 真源保留。目标不是指向 Hub 的链接时会 fail-closed 拒绝。</p><div class=\"mono\">' + esc(target) + '</div>', '解除链接', true, async function () {\n var r = await api('/api/links/remove', { body: { targetDir: dir, slugs: [slug], confirm: 'unlink' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已解除 ' + slug);\n loadLinks();\n });\n });\n\n async function loadRecords() {\n var d = await api('/api/records?limit=200');\n if (d.error) { toast(d.error); return; }\n state.records = d;\n var valid = (d.snapshots || []).filter(function (s) { return s.valid; }).length;\n $('#recordsInfo').textContent = '快照 ' + valid + ' / ' + (d.snapshots || []).length + ' · 证据 ' + (d.evidence.entries || []).length + ' · 审计 ' + (d.audit.entries || []).length;\n renderSnapshots();\n var ev = (d.evidence.entries || []).slice().reverse();\n $('#evidenceEmpty').hidden = ev.length > 0;\n $('#evidenceRows').innerHTML = ev.map(function (x) {\n var tone = x.decision === 'ok' ? 'ok' : x.decision === 'fail' ? 'danger' : 'info';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'event') + ' · ' + esc(x.skill || x.slug || '-') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.ts || x.at)) + (x.version ? ' · v' + esc(x.version) : '') + (x.via ? ' · via ' + esc(x.via) : '') + '</div></div><span class=\"badge ' + tone + '\">' + esc(x.decision || '记录') + '</span></div>';\n }).join('');\n var aud = (d.audit.entries || []).slice().reverse();\n $('#auditEmpty').hidden = aud.length > 0;\n $('#auditRows').innerHTML = aud.map(function (x) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'audit') + ' · ' + esc(x.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.at)) + (x.verdict ? ' · ' + esc(x.verdict) : '') + (x.sourceHost ? ' · ' + esc(x.sourceHost) : '') + '</div></div></div>';\n }).join('');\n }\n function renderSnapshots() {\n var d = state.records; if (!d) return;\n var rows = d.snapshots || [];\n $('#snapshotEmpty').hidden = rows.length > 0;\n $('#snapshotRows').innerHTML = rows.map(function (s) {\n var canRollback = s.valid && s.source;\n return '<tr><td><div class=\"cell-main\">' + esc(s.slug) + '</div><div class=\"cell-sub\">' + esc(s.name) + '</div></td>' +\n '<td>v' + esc(s.version || '-') + '</td><td>' + esc(fmtTime(s.createdAt || s.mtime_ms)) + '</td><td class=\"mono\">' + esc(s.source || '-') + '</td>' +\n '<td>' + (s.valid ? '<span class=\"badge ok\">可用</span>' : '<span class=\"badge danger\">不可用</span>') + (s.legacy ? ' <span class=\"badge warn\">旧格式</span>' : '') + (s.reason ? '<div class=\"cell-sub\">' + esc(s.reason) + '</div>' : '') + '</td>' +\n '<td>' + (canRollback ? '<button class=\"btn small danger\" data-rollback=\"' + esc(s.path) + '\" data-slug=\"' + esc(s.slug) + '\" data-version=\"' + esc(s.version || '') + '\" data-source=\"' + esc(s.source || '') + '\">回滚</button>' : '<span class=\"faint\">CLI 处理</span>') + '</td></tr>';\n }).join('');\n }\n document.querySelectorAll('#view-records [data-tab]').forEach(function (b) {\n b.onclick = function () {\n state.tab = b.dataset.tab;\n Array.prototype.forEach.call(document.querySelectorAll('#view-records [data-tab]'), function (x) { x.classList.toggle('primary', x === b); });\n $('#recSnapshots').hidden = state.tab !== 'snapshots';\n $('#recEvidence').hidden = state.tab !== 'evidence';\n $('#recAudit').hidden = state.tab !== 'audit';\n };\n });\n document.querySelector('#view-records [data-tab=\"snapshots\"]').classList.add('primary');\n $('#snapshotRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-rollback]');\n if (!b) return;\n var slug = b.dataset.slug, snap = b.dataset.rollback, source = b.dataset.source;\n confirmModal('回滚 ' + slug + ' 到 v' + b.dataset.version + '?', '<p class=\"label\">将用快照覆盖安装来源目录;恢复后自动体检并写入证据。</p><div class=\"mono\">' + esc(source) + '</div>', '执行回滚', true, async function () {\n var r = await api('/api/rollback/apply', { body: { slug: slug, snapshot: snap, confirm: 'rollback' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.ok ? '回滚完成(doctor 通过)' : '回滚完成,但体检未通过');\n loadRecords();\n loadOverview();\n });\n });\n\n $('#routeBtn').onclick = runRoute;\n $('#routeInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') runRoute(); });\n async function runRoute() {\n var request = $('#routeInput').value.trim();\n if (!request) { toast('先输入一句需求'); return; }\n var seq = showLoad('正在生成路由与编排…');\n $('#routeBtn').disabled = true;\n var d = await api('/api/route?request=' + encodeURIComponent(request));\n hideLoad(seq);\n $('#routeBtn').disabled = false;\n if (d.error) { toast(d.error); return; }\n var skills = (d.skills || []).map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + s.order + '. ' + esc(s.slug) + ' <span class=\"badge ' + (s.installed ? 'ok' : 'warn') + '\">' + esc(s.installed ? '已装' + (s.version ? ' v' + s.version : '') : '缺失') + '</span></div><div class=\"row-meta\">' + esc(s.role || '') + '</div></div></div>';\n }).join('');\n var missing = (d.missing_skills || []).length\n ? '<div class=\"section panel\"><h3>缺失技能</h3><div class=\"toolbar\"><span class=\"mono\">' + esc((d.missing_skills || []).map(function (s) { return s.slug; }).join(', ')) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(d.install_command || '') + '\">复制安装命令</button></div><div class=\"note\">安装与信任判断由你决定;元信装前扫描默认启用。</div></div>'\n : '';\n var others = (d.other_skill_candidates || []).length\n ? '<div class=\"section panel\"><h3>其他已装技能候选</h3>' + d.other_skill_candidates.map(function (c) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge info\">v' + esc(c.version) + '</span></div><div class=\"row-meta\">' + esc((c.matched_terms || []).join('、')) + ' · ' + esc(c.description || '') + '</div></div></div>';\n }).join('') + '<div class=\"note\">只读 frontmatter 机械匹配;使用前请自行确认。</div></div>'\n : '';\n $('#routeResult').innerHTML = '<div class=\"panel\"><div class=\"shead\"><h2>' + esc(d.playbook.name) + '</h2><span class=\"badge\">置信度 ' + esc(d.confidence) + '</span></div>' +\n '<div class=\"label\">' + esc(d.playbook.intent) + '</div><div class=\"list\">' + skills + '</div>' +\n '<div class=\"note\">动态路由:' + esc((d.dynamic && d.dynamic.status) || '未启用') + ' · 匹配词:' + esc((d.matched_keywords || []).join('、') || '无') + '</div></div>' + missing + others;\n }\n $('#routeResult').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n 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: '链接到已核实宿主(默认范围)' },\n { cmd: 'yotta-skills hub link --all --include-discovered', why: '显式纳入自动发现目录' },\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 runLoad('overview');\n })();\n})();\n</script>\n</body>\n</html>\n";
|
|
65
|
+
const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .loadbar{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px;padding:10px 13px;border:1px dashed var(--line2);border-radius:8px;margin:0 0 14px;background:var(--panel)}\n .spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--acc);animation:spin .8s linear infinite;flex:none}\n @keyframes spin{to{transform:rotate(360deg)}}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .chips{display:flex;gap:6px;flex-wrap:wrap}\n .chip{cursor:pointer;background:transparent}\n .chip.active{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n .cmd-detail{border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:8px;padding:0 11px;min-width:0}\n .cmd-detail summary{cursor:pointer;padding:9px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}\n .cmd-detail .note{padding:0 0 8px}\n .cmd-detail .cmd{margin:0 0 8px}\n\n @media (max-width:980px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:13px;gap:11px}\n .nav{flex-direction:row;overflow-x:auto;width:100%;scrollbar-width:none;-webkit-overflow-scrolling:touch}\n .nav::-webkit-scrollbar{display:none}\n .nav button{white-space:nowrap;padding:7px 9px}\n .side-foot{margin-top:0;flex-direction:row;align-items:center}\n .privacy{display:none}\n .ghost-btn{flex:1;white-space:nowrap}\n .verline{white-space:nowrap;text-align:right}\n .main{padding:16px 14px 56px}\n .g4,.g2{grid-template-columns:minmax(0,1fr)}\n .search{min-width:100%}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}\n }\n</style>\n</head>\n<body>\n<div class=\"shell\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3 4 7v4c0 4.4 3.4 8.4 8 10 4.6-1.6 8-5.6 8-10V7z\"/><path d=\"M9 12l2 2 4-4\"/></svg></div>\n <div><div class=\"brand-name\">元阁 · 技能枢纽</div><div class=\"brand-sub\">Hub 本地面板</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"hosts\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"13\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 7.5h.01M7 16.5h.01\"/></svg>宿主矩阵</button>\n <button data-view=\"adopt\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3v12\"/><path d=\"m7 10 5 5 5-5\"/><path d=\"M4 21h16\"/></svg>收编向导</button>\n <button data-view=\"links\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1L11 4.9\"/><path d=\"M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1-1\"/></svg>链接与体检</button>\n <button data-view=\"records\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/><path d=\"M12 8v5l3 2\"/></svg>记录与回滚</button>\n <button data-view=\"route\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"18\" cy=\"18\" r=\"2.5\"/><path d=\"M8.5 6H15a3 3 0 0 1 0 6h-6a3 3 0 0 0 0 6h6.5\"/></svg>路由与编排</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>高级 CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>本机运行 · 零远程<br>写操作需页面令牌与确认</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n <div class=\"verline\" id=\"verLine\"></div>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">Hub 里有什么、健康吗、最近做了什么</div></div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"refreshBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>刷新</button>\n <div class=\"chip\"><span class=\"dot\"></span>本机 · 127.0.0.1</div>\n </div>\n <div class=\"loadbar\" id=\"loadbar\" hidden><span class=\"spinner\"></span><span id=\"loadbarText\">正在加载…</span></div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\" id=\"ovCards\"></div>\n <div class=\"section\" id=\"ovDoctor\"></div>\n <div class=\"section grid g2\">\n <div class=\"panel\"><h3>Hub 内容</h3><div class=\"faint\">技能真源在 Hub 目录,宿主目录通过链接分发。</div>\n <label class=\"search section\" style=\"min-width:100%\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"ovSkillFilter\" placeholder=\"过滤技能(输入后显示全部匹配)\"></label>\n <div class=\"list\" id=\"ovSkills\"></div>\n </div>\n <div class=\"panel\"><h3>最近动作</h3><div class=\"faint\">收编 / 链接 / 解除都写入 Hub 审计。</div><div class=\"list\" id=\"ovAudit\"></div></div>\n </div>\n <div class=\"section panel\">\n <h3>Hub 位置</h3>\n <div class=\"mono\" id=\"ovHubDir\">-</div>\n <div class=\"toolbar section\">\n <button class=\"btn small\" data-goto=\"adopt\">去收编</button>\n <button class=\"btn small\" data-goto=\"links\">去链接与体检</button>\n <button class=\"btn small\" data-goto=\"records\">查看记录</button>\n <button class=\"btn small\" id=\"copyHubDir\">复制路径</button>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-hosts\">\n <div class=\"toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"hostSearch\" placeholder=\"过滤宿主 / 目录\"></label>\n <div class=\"chips\" id=\"hostStateChips\"></div>\n <button class=\"btn small\" id=\"hostAddBtn\">添加自定义目录</button>\n </div>\n <div class=\"section panel\">\n <div class=\"faint\" id=\"hostSummary\"></div>\n <table><thead><tr><th>宿主</th><th>技能目录</th><th>范围</th><th>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th><th></th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);默认范围 = 已核实宿主,自动发现目录需在 CLI 用 --include-discovered 显式纳入,锁 / 数据桥接目录永不链接;元技能链接时收敛旧副本(移入回收站,保留 7 天可恢复)。</div>\n <div class=\"list\" id=\"linkTargets\"></div>\n </div>\n <div class=\"section panel\">\n <h3>当前链接</h3>\n <table><thead><tr><th>技能</th><th>宿主</th><th>目标</th><th>状态</th><th></th></tr></thead><tbody id=\"linkRows\"></tbody></table>\n <div class=\"empty\" id=\"linkEmpty\" hidden>还没有任何链接。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-records\">\n <div class=\"toolbar\">\n <button class=\"btn small\" data-tab=\"snapshots\">回滚快照</button>\n <button class=\"btn small\" data-tab=\"evidence\">安装 / 回滚证据</button>\n <button class=\"btn small\" data-tab=\"audit\">Hub 审计</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"recordsInfo\"></span>\n </div>\n <div class=\"section panel\" id=\"recSnapshots\">\n <table><thead><tr><th>技能</th><th>版本</th><th>时间</th><th>来源目录</th><th>状态</th><th></th></tr></thead><tbody id=\"snapshotRows\"></tbody></table>\n <div class=\"empty\" id=\"snapshotEmpty\" hidden>没有安装快照。快照由 install / update 写入。</div>\n </div>\n <div class=\"section panel\" id=\"recEvidence\" hidden>\n <div class=\"list\" id=\"evidenceRows\"></div>\n <div class=\"empty\" id=\"evidenceEmpty\" hidden>没有证据记录。</div>\n </div>\n <div class=\"section panel\" id=\"recAudit\" hidden>\n <div class=\"list\" id=\"auditRows\"></div>\n <div class=\"empty\" id=\"auditEmpty\" hidden>没有 Hub 审计记录。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-route\">\n <div class=\"panel\">\n <h3>一句话需求 → 技能组合</h3>\n <div class=\"faint\">只读调用静态 playbook(7 组)+ 动态路由块;安装建议只给命令,不在网页执行。</div>\n <div class=\"toolbar section\">\n <label class=\"search\" style=\"flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"routeInput\" placeholder=\"例如:帮我做发布前质量检查\"></label>\n <button class=\"btn primary\" id=\"routeBtn\">给出组合</button>\n </div>\n </div>\n <div id=\"routeResult\" class=\"section\"></div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"banner\">\n <svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>\n <div><div class=\"banner-title\">高级 CLI(全量)</div><div class=\"banner-body\">全部命令 + 人话说明 + 可复制示例;网页只展示与复制,不代替终端执行安装、更新与删除。</div></div>\n </div>\n <div class=\"section panel\">\n <div class=\"shead\"><h2>命令速查</h2><span class=\"faint\" id=\"cliMeta\">加载中…</span></div>\n <div id=\"cliQuick\"></div>\n </div>\n <div class=\"section panel\">\n <details id=\"cliFullWrap\">\n <summary>完整命令与参数(点击展开)</summary>\n <div id=\"cliFull\"></div>\n </details>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"mask\"><div class=\"modal\" id=\"modal\"></div></div>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n var TOKEN = (document.querySelector('meta[name=\"yotta-view-token\"]') || {}).content || '';\n var state = { view: 'overview', overview: null, hosts: null, adopt: null, selected: {}, records: null, tab: 'snapshots', hostFilter: '', hostStateFilter: '', adoptFilter: '', ovSkillFilter: '' };\n\n function $(sel) { return document.querySelector(sel); }\n function esc(value) {\n return String(value === null || value === undefined ? '' : value)\n .replace(/&/g, '&').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 }\n\n function renderOverviewSkills() {\n var d = state.overview;\n if (!d) return;\n var skills = (d.status && d.status.skills) || [];\n var filter = state.ovSkillFilter.trim().toLowerCase();\n var list = filter\n ? skills.filter(function (s) { return String(s.slug || '').toLowerCase().indexOf(filter) >= 0; })\n : skills.slice(0, 8);\n $('#ovSkills').innerHTML = list.length ? list.map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(s.slug) + '</div><div class=\"row-meta\">v' + esc(s.version || '-') + ' · ' + esc(s.origin === 'external' ? '外部来源' : '元阁家族') + ' · 链接 ' + (s.links || []).length + '</div></div>' +\n '<span class=\"badge ' + (s.status === 'present' ? 'ok' : 'danger') + '\">' + esc(s.status === 'present' ? '已入库' : '缺失') + '</span>' +\n '<div class=\"row-actions\"><button class=\"btn small danger\" data-remove-skill=\"' + esc(s.slug) + '\">删除</button></div></div>';\n }).join('') : '<div class=\"empty\">' + (filter ? '没有匹配的技能。' : 'Hub 还是空的。先在「收编向导」把已装技能收编进来。') + '</div>';\n }\n $('#ovSkillFilter').addEventListener('input', function () {\n state.ovSkillFilter = $('#ovSkillFilter').value || '';\n renderOverviewSkills();\n });\n $('#ovSkills').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-remove-skill]');\n if (!b) return;\n var slug = b.dataset.removeSkill;\n var plan = await api('/api/skills/remove-plan?slug=' + encodeURIComponent(slug));\n if (!plan || plan.error) { toast((plan && plan.error) || '影响预览失败'); return; }\n var targets = plan.targets || [];\n var willUnlink = targets.filter(function (t) { return t.action === 'unlink'; });\n var kept = targets.filter(function (t) { return t.action === 'keep'; });\n var rows = targets.map(function (t) {\n var tone = t.action === 'unlink' ? 'warn' : t.action === 'keep' ? 'info' : '';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(t.action === 'unlink' ? '将清理' : t.action === 'keep' ? '保留' : '无动作') + '</span><span class=\"mono\">' + esc(t.dir) + '</span><span class=\"faint\">' + esc(t.note || '') + '</span></div>';\n }).join('');\n var body = '<p class=\"label\">将清理 ' + willUnlink.length + ' 条宿主链接(含死链)' +\n (plan.hubPresent ? ',并把 Hub 目录移入回收站(保留 7 天)' : ';Hub 目录已缺失,仅清理残留') +\n ';保留 ' + kept.length + ' 项宿主真副本 / 外部链接。</p>' +\n '<div class=\"result-list\">' + (rows || '<div class=\"faint\">没有需要处理的宿主目录。</div>') + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入完整技能名确认:' + esc(slug) + '</label><input id=\"removeConfirmSlug\" placeholder=\"' + esc(slug) + '\"></div>';\n confirmModal('删除 Hub 技能 ' + slug + '?', body, '删除', true, async function () {\n var typed = ($('#removeConfirmSlug') && $('#removeConfirmSlug').value || '').trim();\n if (typed !== slug) { toast('技能名不匹配'); return; }\n var r = await api('/api/skills/remove', { body: { slug: slug, confirm: 'remove', confirmSlug: typed } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.verdict === 'cleanup-only' ? '已清理残留(Hub 目录原本缺失)' : '已删除 ' + slug + '(目录已入回收站)');\n loadOverview();\n });\n });\n\n async function loadHosts() {\n var d = await api('/api/hosts');\n if (d.error) { toast(d.error); return; }\n state.hosts = d;\n var s = d.summary.states || {};\n $('#hostSummary').textContent = '可用 ' + (s.available || 0) + ' · 残留 ' + (s.orphan || 0) + ' · 未创建 ' + (s.missing || 0) + ' · 仅标记 ' + (s.markerOnly || 0) + (s.ignored ? ' · 已忽略 ' + s.ignored : '') + ' · 链接 ' + d.summary.links + '(异常 ' + d.summary.brokenLinks + ')';\n renderHostStateChips();\n renderHosts();\n var marks = d.installed || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + (m.hasSkillsDir ? ' · 技能目录:' + esc(m.skillDir || '') : ' · 未发现技能目录') + '</div></div><span class=\"badge ' + (m.hasSkillsDir ? 'ok' : 'info') + '\">' + esc(m.hasSkillsDir ? '可链接' : '仅标记') + '</span></div>';\n }).join('') : '<div class=\"empty\">没有检测到额外宿主标记。</div>';\n }\n function renderHostStateChips() {\n var d = state.hosts; if (!d) return;\n var s = d.summary.states || {};\n var chips = [\n ['', '全部'],\n ['available', '可用 ' + (s.available || 0)],\n ['orphan', '残留 ' + (s.orphan || 0)],\n ['missing', '未创建 ' + (s.missing || 0)],\n ['marker-only', '仅标记 ' + (s.markerOnly || 0)],\n ];\n $('#hostStateChips').innerHTML = chips.map(function (c) {\n return '<button class=\"chip' + (state.hostStateFilter === c[0] ? ' active' : '') + '\" data-host-state=\"' + esc(c[0]) + '\">' + esc(c[1]) + '</button>';\n }).join('');\n }\n function hostStateBadge(h) {\n if (h.bridgeOnly) return '<span class=\"badge info\">桥接</span>';\n var map = { available: ['ok', '可用'], orphan: ['warn', '残留'], missing: ['', '未创建'], 'marker-only': ['info', '仅标记'], ignored: ['', '已忽略'] };\n var item = map[h.state] || ['', h.state || '未知'];\n return '<span class=\"badge ' + item[0] + '\">' + esc(item[1]) + (h.stateSource === 'manual' ? '·手动' : '') + '</span>' + (h.brokenLinks ? ' <span class=\"badge danger\">异常</span>' : '');\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var sf = state.hostStateFilter;\n var rows = (d.hosts || []).filter(function (h) {\n if (sf && h.state !== sf) return false;\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n $('#hostRows').innerHTML = rows.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>' : (h.detection === 'user-registry' ? '<span class=\"badge info\">用户注册</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>');\n var action = '';\n if (h.exists && h.state === 'orphan' && !h.bridgeOnly) action += '<button class=\"btn small danger\" data-host-purge=\"' + esc(h.dir) + '\">清理</button> ';\n if (h.detection === 'user-registry') action += '<button class=\"btn small\" data-host-unregister=\"' + esc(h.dir) + '\">移除注册</button>';\n return '<tr><td><div class=\"cell-main\">' + esc(h.label) + '</div><div class=\"cell-sub\">' + esc(h.agentId || (h.known ? '已知宿主' : h.detection || '自动发现')) + '</div></td>' +\n '<td><div class=\"mono\">' + esc(h.dir) + '</div><button class=\"btn small\" data-copy=\"' + esc(h.dir) + '\">复制</button></td>' +\n '<td>' + scopeBadge + '</td>' +\n '<td>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + hostStateBadge(h) + '</td><td>' + action + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostStateChips').addEventListener('click', function (e) {\n var b = e.target.closest('[data-host-state]');\n if (!b) return;\n state.hostStateFilter = b.dataset.hostState;\n renderHostStateChips();\n renderHosts();\n });\n $('#hostAddBtn').addEventListener('click', function () {\n var body = '<p class=\"label\">注册自定义宿主目录:纳入发现 / 分发 / 收编。不创建目录、不改宿主配置。</p>' +\n '<div class=\"section\"><label class=\"label\">目录(必须已存在)</label><input id=\"hostAddDir\" placeholder=\"例如 D:\\\\my-agent\\\\skills\"></div>' +\n '<div class=\"section\"><label class=\"label\">名称(可选,默认目录名)</label><input id=\"hostAddLabel\" placeholder=\"我的宿主\"></div>';\n openModal('添加自定义目录', body, {\n okText: '注册',\n onOk: async function () {\n var dir = ($('#hostAddDir') && $('#hostAddDir').value || '').trim();\n var label = ($('#hostAddLabel') && $('#hostAddLabel').value || '').trim();\n if (!dir) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/add', { body: { dir: dir, label: label } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已注册:' + ((r.entry && r.entry.label) || dir));\n loadHosts();\n },\n });\n });\n async function openHostPurgeModal(dir) {\n var plan = await api('/api/hosts/purge-plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var body = '<p class=\"label\">只删除指向 Hub 的链接(含死链);非 Hub 链接 / 非技能内容随目录保留在回收站(7 天可恢复)。</p>' +\n '<div class=\"result\"><span class=\"badge warn\">将解除链接</span><b>' + esc(plan.unlinkCount) + '</b><span>条(保留 ' + esc(plan.keptLinks) + ' 非 Hub 链接 / ' + esc(plan.keptEntries) + ' 非链接内容)</span></div>' +\n '<div class=\"mono\">' + esc(dir) + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入 purge 确认执行:</label><input id=\"hostPurgeConfirm\" placeholder=\"purge\"></div>';\n confirmModal('清理残留目录?', body, '清理(移入回收站)', true, async function () {\n var typed = ($('#hostPurgeConfirm') && $('#hostPurgeConfirm').value || '').trim();\n if (typed !== 'purge') { toast('确认串不匹配(输入 purge)'); return; }\n var r = await api('/api/hosts/purge', { body: { dir: dir, confirm: 'purge' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清理:解除 ' + r.unlinkCount + ' 条链接;回收站保留 7 天');\n loadHosts();\n });\n }\n $('#hostRows').addEventListener('click', async function (e) {\n var copyBtn = e.target.closest('[data-copy]');\n if (copyBtn) { copyText(copyBtn.dataset.copy); return; }\n var purgeBtn = e.target.closest('[data-host-purge]');\n if (purgeBtn) { openHostPurgeModal(purgeBtn.dataset.hostPurge); return; }\n var unregBtn = e.target.closest('[data-host-unregister]');\n if (unregBtn) {\n var dir = unregBtn.dataset.hostUnregister;\n confirmModal('移除注册?', '<p class=\"label\">只移除自定义宿主注册,不删除目录、不改宿主配置。</p><div class=\"mono\">' + esc(dir) + '</div>', '移除注册', false, async function () {\n var r = await api('/api/hosts/remove', { body: { dir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已移除注册(目录保留)');\n loadHosts();\n });\n }\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链接</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n var action = h.bridgeOnly ? '' : '<button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button>';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + ' ' + scopeBadge + '</div><div class=\"row-meta mono\">' + esc(h.dir) + '</div><div class=\"row-meta\">目录技能 ' + esc(h.skillCount || 0) + ' · 指向 Hub ' + esc(h.hubLinks || 0) + (h.brokenLinks ? ' · 异常 ' + h.brokenLinks : '') + '</div></div><div class=\"row-actions\">' + action + '</div></div>';\n }).join('') : '<div class=\"empty\">尚未发现可用的宿主技能目录。</div>';\n var rows = links.links || [];\n $('#linkEmpty').hidden = rows.length > 0;\n $('#linkRows').innerHTML = rows.map(function (l) {\n var tone = l.status === 'ok' ? 'ok' : l.status === 'broken' ? 'danger' : 'warn';\n return '<tr><td><div class=\"cell-main\">' + esc(l.slug) + '</div><div class=\"cell-sub\">' + esc(l.label || l.agent || '-') + '</div></td>' +\n '<td class=\"mono\">' + esc(l.dir) + '</td><td class=\"mono\">' + esc(l.target) + '</td>' +\n '<td><span class=\"badge ' + tone + '\">' + esc(l.status) + '</span></td>' +\n '<td><button class=\"btn small danger\" data-unlink=\"' + esc(l.target) + '\" data-dir=\"' + esc(l.dir) + '\" data-slug=\"' + esc(l.slug) + '\">解除</button></td></tr>';\n }).join('');\n }\n $('#linkTargets').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-link-plan]');\n if (!b) return;\n var dir = b.dataset.linkPlan;\n var plan = await api('/api/links/plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var would = plan.results.filter(function (r) { return r.status === 'would-link'; });\n var converge = plan.results.filter(function (r) { return r.status === 'would-converge'; });\n var skipped = plan.results.filter(function (r) { return r.status === 'skipped'; });\n var conflicts = plan.results.filter(function (r) { return r.status === 'conflict'; });\n var convergeMoves = converge.reduce(function (n, r) { return n + ((r.moves && r.moves.length) || 0); }, 0);\n var linkable = would.length + converge.length;\n var body = '<p class=\"label\">目标:' + esc(plan.label || '') + ' · <span class=\"mono\">' + esc(dir) + '</span></p>' +\n '<div class=\"result\"><span class=\"badge ok\">将建立</span><b>' + linkable + '</b><span>个链接</span></div>' +\n (converge.length ? '<div class=\"result\"><span class=\"badge warn\">将收敛</span><b>' + convergeMoves + '</b><span>份旧副本 → 回收站(保留 7 天,可恢复):' + esc(converge.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (skipped.length ? '<div class=\"result\"><span class=\"badge warn\">跳过</span><b>' + skipped.length + '</b><span>项(Hub 版本旧于宿主 / 版本无法解析):' + esc(skipped.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (conflicts.length ? '<div class=\"result\"><span class=\"badge warn\">跳过冲突</span><b>' + conflicts.length + '</b><span>个同名目录 / 链接(默认不覆盖)</span></div>' : '') +\n (linkable ? '<details><summary>查看明细</summary><div class=\"variants\">' + would.concat(converge).map(function (r) { return '<div class=\"variant\"><span class=\"mono\">' + esc(r.slug) + '</span></div>'; }).join('') + '</div></details>' : '<p class=\"faint\">没有可建立的链接。</p>');\n openModal('链接预览', body, {\n okText: linkable ? '执行链接' : null,\n onOk: async function () {\n var r = await api('/api/links/apply', { body: { targetDir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var done = (r.results || []).filter(function (x) { return x.status === 'linked'; }).length;\n var moved = (r.results || []).reduce(function (n, x) { return n + ((x.moved && x.moved.length) || 0); }, 0);\n toast('已建立 ' + done + ' 个链接' + (moved ? ';收敛 ' + moved + ' 份旧副本(回收站 7 天)' : ''));\n loadLinks();\n },\n });\n });\n $('#linkRows').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-unlink]');\n if (!b) return;\n var target = b.dataset.unlink;\n var dir = b.dataset.dir;\n var slug = b.dataset.slug;\n confirmModal('解除链接 ' + slug + '?', '<p class=\"label\">只删除宿主目录中的链接,Hub 真源保留。目标不是指向 Hub 的链接时会 fail-closed 拒绝。</p><div class=\"mono\">' + esc(target) + '</div>', '解除链接', true, async function () {\n var r = await api('/api/links/remove', { body: { targetDir: dir, slugs: [slug], confirm: 'unlink' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已解除 ' + slug);\n loadLinks();\n });\n });\n\n async function loadRecords() {\n var d = await api('/api/records?limit=200');\n if (d.error) { toast(d.error); return; }\n state.records = d;\n var valid = (d.snapshots || []).filter(function (s) { return s.valid; }).length;\n $('#recordsInfo').textContent = '快照 ' + valid + ' / ' + (d.snapshots || []).length + ' · 证据 ' + (d.evidence.entries || []).length + ' · 审计 ' + (d.audit.entries || []).length;\n renderSnapshots();\n var ev = (d.evidence.entries || []).slice().reverse();\n $('#evidenceEmpty').hidden = ev.length > 0;\n $('#evidenceRows').innerHTML = ev.map(function (x) {\n var tone = x.decision === 'ok' ? 'ok' : x.decision === 'fail' ? 'danger' : 'info';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'event') + ' · ' + esc(x.skill || x.slug || '-') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.ts || x.at)) + (x.version ? ' · v' + esc(x.version) : '') + (x.via ? ' · via ' + esc(x.via) : '') + '</div></div><span class=\"badge ' + tone + '\">' + esc(x.decision || '记录') + '</span></div>';\n }).join('');\n var aud = (d.audit.entries || []).slice().reverse();\n $('#auditEmpty').hidden = aud.length > 0;\n $('#auditRows').innerHTML = aud.map(function (x) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'audit') + ' · ' + esc(x.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.at)) + (x.verdict ? ' · ' + esc(x.verdict) : '') + (x.sourceHost ? ' · ' + esc(x.sourceHost) : '') + '</div></div></div>';\n }).join('');\n }\n function renderSnapshots() {\n var d = state.records; if (!d) return;\n var rows = d.snapshots || [];\n $('#snapshotEmpty').hidden = rows.length > 0;\n $('#snapshotRows').innerHTML = rows.map(function (s) {\n var canRollback = s.valid && s.source;\n return '<tr><td><div class=\"cell-main\">' + esc(s.slug) + '</div><div class=\"cell-sub\">' + esc(s.name) + '</div></td>' +\n '<td>v' + esc(s.version || '-') + '</td><td>' + esc(fmtTime(s.createdAt || s.mtime_ms)) + '</td><td class=\"mono\">' + esc(s.source || '-') + '</td>' +\n '<td>' + (s.valid ? '<span class=\"badge ok\">可用</span>' : '<span class=\"badge danger\">不可用</span>') + (s.legacy ? ' <span class=\"badge warn\">旧格式</span>' : '') + (s.reason ? '<div class=\"cell-sub\">' + esc(s.reason) + '</div>' : '') + '</td>' +\n '<td>' + (canRollback ? '<button class=\"btn small danger\" data-rollback=\"' + esc(s.path) + '\" data-slug=\"' + esc(s.slug) + '\" data-version=\"' + esc(s.version || '') + '\" data-source=\"' + esc(s.source || '') + '\">回滚</button>' : '<span class=\"faint\">CLI 处理</span>') + '</td></tr>';\n }).join('');\n }\n document.querySelectorAll('#view-records [data-tab]').forEach(function (b) {\n b.onclick = function () {\n state.tab = b.dataset.tab;\n Array.prototype.forEach.call(document.querySelectorAll('#view-records [data-tab]'), function (x) { x.classList.toggle('primary', x === b); });\n $('#recSnapshots').hidden = state.tab !== 'snapshots';\n $('#recEvidence').hidden = state.tab !== 'evidence';\n $('#recAudit').hidden = state.tab !== 'audit';\n };\n });\n document.querySelector('#view-records [data-tab=\"snapshots\"]').classList.add('primary');\n $('#snapshotRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-rollback]');\n if (!b) return;\n var slug = b.dataset.slug, snap = b.dataset.rollback, source = b.dataset.source;\n confirmModal('回滚 ' + slug + ' 到 v' + b.dataset.version + '?', '<p class=\"label\">将用快照覆盖安装来源目录;恢复后自动体检并写入证据。</p><div class=\"mono\">' + esc(source) + '</div>', '执行回滚', true, async function () {\n var r = await api('/api/rollback/apply', { body: { slug: slug, snapshot: snap, confirm: 'rollback' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.ok ? '回滚完成(doctor 通过)' : '回滚完成,但体检未通过');\n loadRecords();\n loadOverview();\n });\n });\n\n $('#routeBtn').onclick = runRoute;\n $('#routeInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') runRoute(); });\n async function runRoute() {\n var request = $('#routeInput').value.trim();\n if (!request) { toast('先输入一句需求'); return; }\n var seq = showLoad('正在生成路由与编排…');\n $('#routeBtn').disabled = true;\n var d = await api('/api/route?request=' + encodeURIComponent(request));\n hideLoad(seq);\n $('#routeBtn').disabled = false;\n if (d.error) { toast(d.error); return; }\n var skills = (d.skills || []).map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + s.order + '. ' + esc(s.slug) + ' <span class=\"badge ' + (s.installed ? 'ok' : 'warn') + '\">' + esc(s.installed ? '已装' + (s.version ? ' v' + s.version : '') : '缺失') + '</span></div><div class=\"row-meta\">' + esc(s.role || '') + '</div></div></div>';\n }).join('');\n var missing = (d.missing_skills || []).length\n ? '<div class=\"section panel\"><h3>缺失技能</h3><div class=\"toolbar\"><span class=\"mono\">' + esc((d.missing_skills || []).map(function (s) { return s.slug; }).join(', ')) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(d.install_command || '') + '\">复制安装命令</button></div><div class=\"note\">安装与信任判断由你决定;元信装前扫描默认启用。</div></div>'\n : '';\n var others = (d.other_skill_candidates || []).length\n ? '<div class=\"section panel\"><h3>其他已装技能候选</h3>' + d.other_skill_candidates.map(function (c) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge info\">v' + esc(c.version) + '</span></div><div class=\"row-meta\">' + esc((c.matched_terms || []).join('、')) + ' · ' + esc(c.description || '') + '</div></div></div>';\n }).join('') + '<div class=\"note\">只读 frontmatter 机械匹配;使用前请自行确认。</div></div>'\n : '';\n $('#routeResult').innerHTML = '<div class=\"panel\"><div class=\"shead\"><h2>' + esc(d.playbook.name) + '</h2><span class=\"badge\">置信度 ' + esc(d.confidence) + '</span></div>' +\n '<div class=\"label\">' + esc(d.playbook.intent) + '</div><div class=\"list\">' + skills + '</div>' +\n '<div class=\"note\">动态路由:' + esc((d.dynamic && d.dynamic.status) || '未启用') + ' · 匹配词:' + esc((d.matched_keywords || []).join('、') || '无') + '</div></div>' + missing + others;\n }\n $('#routeResult').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n /* 高级 CLI:/api/help 单一真源(与 CLI --help 同源) */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function renderCliHelp(d) {\n if (!d || d.error) { $('#cliMeta').textContent = '帮助加载失败'; return; }\n var total = 0;\n (d.groups || []).forEach(function (g) {\n (g.commands || []).forEach(function (c) { total += 1 + (c.subcommands || []).length; });\n });\n $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + (d.options || []).length;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.items || []).map(function (item) {\n var meta = findHelpEntry(d.groups, item.cmd) || {};\n return '<div class=\"cmd\"><code>' + esc(item.example) + '</code><span class=\"why\">' + esc(meta.what || '') +\n (item.note ? ' · ' + esc(item.note) : '') + '</span>' +\n (item.danger ? '<span class=\"badge danger\">危险</span>' : '') +\n '<button class=\"btn small\" data-copy-cmd=\"' + esc(item.example) + '\">复制</button></div>';\n }).join('') + '</div>';\n }).join('');\n var full = (d.groups || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.commands || []).map(function (c) {\n var opts = (c.options || []).length\n ? '<div class=\"note\">常用选项: ' + c.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'\n : '';\n var subs = (c.subcommands || []).map(function (s) {\n return '<div class=\"cmd\"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class=\"why\">' + esc(s.what || '') + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc('yotta-skills ' + s.usage) + '\">复制</button></div>';\n }).join('');\n return '<details class=\"cmd-detail\"><summary><code>' + esc('yotta-skills ' + c.usage) + '</code> <span class=\"why\">' + esc(c.what || '') + '</span></summary>' +\n (c.when ? '<div class=\"note\">' + esc(c.when) + '</div>' : '') + opts + subs + '</details>';\n }).join('') + '</div>';\n }).join('');\n full += '<div class=\"cmd-group\"><h3>完整选项</h3>' + (d.options || []).map(function (o) {\n return '<div class=\"cmd\"><code>' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</code><span class=\"why\">' + esc(o.what || '') +\n (o.when ? ' · ' + esc(o.when) : '') + (o.caution ? ' · ⚠ ' + esc(o.caution) : '') + '</span></div>';\n }).join('') + '</div>';\n $('#cliFull').innerHTML = full;\n }\n api('/api/help').then(renderCliHelp);\n document.addEventListener('click', function (e) {\n var b = e.target.closest('#view-cli [data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n (async function boot() {\n var s = await api('/api/status');\n if (s && !s.error) $('#verLine').textContent = 'v' + s.version + ' · ' + (s.standard || '');\n else $('#verLine').textContent = '未连接本地服务';\n runLoad('overview');\n })();\n})();\n</script>\n</body>\n</html>\n";
|
|
63
66
|
// @generated view-html:end
|
|
64
67
|
|
|
65
68
|
function loadManifest() {
|
|
@@ -213,6 +216,10 @@ function parseArgs(argv) {
|
|
|
213
216
|
else if (a === '--domain') opts.domain = take('--domain').toLowerCase();
|
|
214
217
|
else if (a === '--installed-only') opts.installedOnly = true;
|
|
215
218
|
else if (a === '--hub') opts.hub = take('--hub');
|
|
219
|
+
else if (a === '--state') opts.state = take('--state').toLowerCase();
|
|
220
|
+
else if (a === '--label') opts.label = take('--label');
|
|
221
|
+
else if (a === '--id') opts.id = take('--id').toLowerCase();
|
|
222
|
+
else if (a === '--purge') opts.purge = true;
|
|
216
223
|
else if (a === '--all') opts.all = true;
|
|
217
224
|
else if (a === '--include-discovered') opts.includeDiscovered = true;
|
|
218
225
|
else if (a === '--in-place') opts.inPlace = true;
|
|
@@ -255,8 +262,8 @@ function parseArgs(argv) {
|
|
|
255
262
|
opts.rest.push(p);
|
|
256
263
|
continue;
|
|
257
264
|
}
|
|
258
|
-
if (p === 'install' || p === '
|
|
259
|
-
|| p === 'hub' || p === 'view'
|
|
265
|
+
if (p === 'install' || p === 'install-self' || p === 'update' || p === 'doctor' || p === 'rollback'
|
|
266
|
+
|| p === 'hook' || p === 'hub' || p === 'view' || p === 'where'
|
|
260
267
|
|| p === 'usage' || p === 'decide-memory') {
|
|
261
268
|
if (opts.command && opts.command !== p) die('命令冲突:' + opts.command + ' 与 ' + p);
|
|
262
269
|
opts.command = p;
|
|
@@ -266,6 +273,9 @@ function parseArgs(argv) {
|
|
|
266
273
|
}
|
|
267
274
|
// 直接给 slug 且无命令 → 视为 install 单个/多个
|
|
268
275
|
if (!opts.command && opts.rest.length > 0) opts.command = 'install';
|
|
276
|
+
// 原始位置参数副本:技能 slug 统一小写匹配,但目录路径(hub hosts add/remove 等)
|
|
277
|
+
// 必须保留原始大小写(Windows 路径大小写不敏感但显示 / 台账需要原样)。
|
|
278
|
+
opts.rawRest = opts.rest.slice();
|
|
269
279
|
opts.skills = opts.rest.map(s => s.toLowerCase());
|
|
270
280
|
if (opts.command === 'hub') {
|
|
271
281
|
opts.hubAction = opts.skills[0] || 'status';
|
|
@@ -280,6 +290,13 @@ function parseArgs(argv) {
|
|
|
280
290
|
if (opts.port !== null && opts.command !== 'view') {
|
|
281
291
|
die('--port 只能与 view 一起使用', 2, '示例:yotta-skills view --port 8789。');
|
|
282
292
|
}
|
|
293
|
+
if (opts.state && !['available', 'orphan', 'missing', 'marker-only', 'ignored'].includes(opts.state)) {
|
|
294
|
+
die('未知 --state: ' + opts.state, 2,
|
|
295
|
+
'可用: available(可用)/ orphan(残留)/ missing(未创建)/ marker-only(仅标记)/ ignored(已忽略)。');
|
|
296
|
+
}
|
|
297
|
+
if (opts.state && !(opts.command === 'hub' && (opts.hubAction === 'hosts' || opts.hubAction === 'discover'))) {
|
|
298
|
+
die('--state 只能与 hub hosts 一起使用', 2, '示例:yotta-skills hub hosts --state orphan。');
|
|
299
|
+
}
|
|
283
300
|
if ((opts.all || opts.includeDiscovered || opts.inPlace || opts.allowUnverified || opts.from || opts.include.length || opts.as || opts.scan || opts.apply)
|
|
284
301
|
&& opts.command !== 'hub') {
|
|
285
302
|
die('--all / --include-discovered / --in-place / --allow-unverified / --from / --include / --as / --scan / --apply 只能与 hub 命令一起使用', 2,
|
|
@@ -898,18 +915,32 @@ const installOne = createInstaller({
|
|
|
898
915
|
appendEvidence: evidenceLib.appendEvidence,
|
|
899
916
|
});
|
|
900
917
|
|
|
918
|
+
function summarizeInstallResults(results) {
|
|
919
|
+
return {
|
|
920
|
+
planned: results.filter(r => r.status === 'planned').length,
|
|
921
|
+
install: results.filter(r => r.status === 'planned' && r.planned === 'install').length,
|
|
922
|
+
update: results.filter(r => r.status === 'planned' && r.planned === 'update').length,
|
|
923
|
+
ok: results.filter(r => r.status === 'ok').length,
|
|
924
|
+
skip: results.filter(r => r.status === 'skip').length,
|
|
925
|
+
failed: results.filter(r => r.status === 'fail').length,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
901
929
|
function runInstall(opts, dest) {
|
|
930
|
+
const quiet = Boolean(opts.dryRun && opts.json);
|
|
902
931
|
const skills = opts.hubScope ? selectHubSkills(opts) : selectSkills(opts);
|
|
903
932
|
if (skills.length === 0) {
|
|
904
|
-
out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
|
|
933
|
+
if (!quiet) out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
|
|
905
934
|
process.exitCode = 2;
|
|
906
|
-
return { failed: 1, exitCode: 2 };
|
|
935
|
+
return { failed: 1, exitCode: 2, results: [], summary: summarizeInstallResults([]) };
|
|
936
|
+
}
|
|
937
|
+
if (!quiet) {
|
|
938
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 安装 ' + skills.length + ' 个技能 -> ' + dest);
|
|
939
|
+
const sample = skills.find((s) => s.version !== 'latest') || null;
|
|
940
|
+
out('版本策略: ' + (opts.pin
|
|
941
|
+
? 'pin(锁死清单精确版本,默认)' + (skills.some((s) => s.version === 'latest') ? ';特殊家族跟随各自 npm latest' : '')
|
|
942
|
+
: 'range(' + (sample ? skillRange(sample) : 'latest') + ',跟随最新 patch)'));
|
|
907
943
|
}
|
|
908
|
-
out('yotta-skills(元阁)v' + VERSION + ' —— 安装 ' + skills.length + ' 个技能 -> ' + dest);
|
|
909
|
-
const sample = skills.find((s) => s.version !== 'latest') || null;
|
|
910
|
-
out('版本策略: ' + (opts.pin
|
|
911
|
-
? 'pin(锁死清单精确版本,默认)' + (skills.some((s) => s.version === 'latest') ? ';特殊家族跟随各自 npm latest' : '')
|
|
912
|
-
: 'range(' + (sample ? skillRange(sample) : 'latest') + ',跟随最新 patch)'));
|
|
913
944
|
const results = [];
|
|
914
945
|
let failed = 0;
|
|
915
946
|
let exitCode = 0;
|
|
@@ -917,42 +948,58 @@ function runInstall(opts, dest) {
|
|
|
917
948
|
const r = installOne(s, dest, opts);
|
|
918
949
|
results.push(r);
|
|
919
950
|
if (r.status === 'ok') {
|
|
920
|
-
if (r.gate && r.gate.mode === 'explicit-unverified') {
|
|
921
|
-
out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
|
|
922
|
-
}
|
|
923
951
|
if (s.slug === 'yotta-verify') recordTrustedVerifier(dest);
|
|
924
|
-
|
|
952
|
+
if (!quiet) {
|
|
953
|
+
if (r.gate && r.gate.mode === 'explicit-unverified') {
|
|
954
|
+
out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
|
|
955
|
+
}
|
|
956
|
+
out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?'));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
else if (r.status === 'planned') {
|
|
960
|
+
if (!quiet) {
|
|
961
|
+
out(' [' + (r.planned === 'update' ? '将更新' : '将安装') + '] ' +
|
|
962
|
+
s.slug.padEnd(22) + s.name + ' ' + (r.note || ''));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
else if (r.status === 'skip') {
|
|
966
|
+
if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ',v' + r.version + ')');
|
|
925
967
|
}
|
|
926
|
-
else if (r.status === 'skip') out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ',v' + r.version + ')');
|
|
927
968
|
else {
|
|
928
969
|
failed++;
|
|
929
970
|
exitCode = Math.max(exitCode, r.exitCode || 1);
|
|
930
|
-
out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
|
|
971
|
+
if (!quiet) out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const summary = summarizeInstallResults(results);
|
|
975
|
+
if (!quiet) {
|
|
976
|
+
out('');
|
|
977
|
+
if (opts.dryRun) {
|
|
978
|
+
out('汇总: 将安装/更新 ' + summary.planned + ' / 已是最新 ' + summary.skip + ' / 失败 ' + failed + '(dry-run 预览)');
|
|
979
|
+
} else {
|
|
980
|
+
out('汇总: 成功 ' + summary.ok + ' / 跳过(已是最新) ' + summary.skip + ' / 失败 ' + failed + '(共 ' + results.length + ')');
|
|
931
981
|
}
|
|
932
982
|
}
|
|
933
|
-
const ok = results.filter(r => r.status === 'ok').length;
|
|
934
|
-
const skip = results.filter(r => r.status === 'skip').length;
|
|
935
|
-
out('');
|
|
936
|
-
out('汇总: 成功 ' + ok + ' / 跳过(已是最新) ' + skip + ' / 失败 ' + failed + '(共 ' + results.length + ')');
|
|
937
983
|
if (failed > 0) process.exitCode = exitCode || 1;
|
|
938
|
-
return { failed, exitCode };
|
|
984
|
+
return { failed, exitCode, results, summary };
|
|
939
985
|
}
|
|
940
986
|
|
|
941
987
|
function runUpdate(opts, dest) {
|
|
988
|
+
const quiet = Boolean(opts.dryRun && opts.json);
|
|
942
989
|
let skills = opts.hubScope ? selectHubSkills(opts) : selectSkills(opts);
|
|
943
|
-
out('yotta-skills(元阁)v' + VERSION + ' —— 增量更新 -> ' + dest);
|
|
990
|
+
if (!quiet) out('yotta-skills(元阁)v' + VERSION + ' —— 增量更新 -> ' + dest);
|
|
944
991
|
if (opts.installedOnly) {
|
|
945
992
|
const candidates = skills.length;
|
|
946
993
|
skills = skills.filter((s) => readInstalledVersion(path.join(dest, s.slug)) !== null);
|
|
947
|
-
out('范围: 仅已安装技能(--installed-only;候选 ' + candidates + ' / 已装 ' + skills.length + ')');
|
|
994
|
+
if (!quiet) out('范围: 仅已安装技能(--installed-only;候选 ' + candidates + ' / 已装 ' + skills.length + ')');
|
|
948
995
|
if (skills.length === 0) {
|
|
949
|
-
out('目标目录未发现已安装的元阁家族技能,无动作(退出码 0)。');
|
|
950
|
-
return { failed: 0, exitCode: 0 };
|
|
996
|
+
if (!quiet) out('目标目录未发现已安装的元阁家族技能,无动作(退出码 0)。');
|
|
997
|
+
return { failed: 0, exitCode: 0, results: [], summary: summarizeInstallResults([]) };
|
|
951
998
|
}
|
|
952
999
|
} else if (skills.length === 0) {
|
|
953
|
-
out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
|
|
1000
|
+
if (!quiet) out('过滤后没有匹配的技能(--only / --domain),未执行任何操作。');
|
|
954
1001
|
process.exitCode = 2;
|
|
955
|
-
return { failed: 1, exitCode: 2 };
|
|
1002
|
+
return { failed: 1, exitCode: 2, results: [], summary: summarizeInstallResults([]) };
|
|
956
1003
|
}
|
|
957
1004
|
const results = [];
|
|
958
1005
|
let failed = 0;
|
|
@@ -960,32 +1007,47 @@ function runUpdate(opts, dest) {
|
|
|
960
1007
|
for (const s of skills) {
|
|
961
1008
|
const existing = readInstalledVersion(path.join(dest, s.slug));
|
|
962
1009
|
if (existing === s.version) {
|
|
963
|
-
out(' - ' + s.slug.padEnd(22) + s.name + ' (已是最新,v' + existing + ')');
|
|
1010
|
+
if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (已是最新,v' + existing + ')');
|
|
964
1011
|
results.push({ skill: s, status: 'skip', version: existing });
|
|
965
1012
|
continue;
|
|
966
1013
|
}
|
|
967
1014
|
const r = installOne(s, dest, opts);
|
|
968
1015
|
results.push(r);
|
|
969
1016
|
if (r.status === 'ok') {
|
|
970
|
-
if (r.gate && r.gate.mode === 'explicit-unverified') {
|
|
971
|
-
out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
|
|
972
|
-
}
|
|
973
1017
|
if (s.slug === 'yotta-verify') recordTrustedVerifier(dest);
|
|
974
|
-
|
|
1018
|
+
if (!quiet) {
|
|
1019
|
+
if (r.gate && r.gate.mode === 'explicit-unverified') {
|
|
1020
|
+
out(' ⚠ ' + s.slug.padEnd(22) + '未执行装前扫描(explicit-unverified)');
|
|
1021
|
+
}
|
|
1022
|
+
out(' ✔ ' + s.slug.padEnd(22) + s.name + ' -> ' + (r.version || '?') + (existing ? '(原 v' + existing + ')' : '(新装)'));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
else if (r.status === 'planned') {
|
|
1026
|
+
if (!quiet) {
|
|
1027
|
+
out(' [' + (r.planned === 'update' ? '将更新' : '将安装') + '] ' +
|
|
1028
|
+
s.slug.padEnd(22) + s.name + ' ' + (r.note || ''));
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else if (r.status === 'skip') {
|
|
1032
|
+
if (!quiet) out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ')');
|
|
975
1033
|
}
|
|
976
|
-
else if (r.status === 'skip') out(' - ' + s.slug.padEnd(22) + s.name + ' (' + r.note + ')');
|
|
977
1034
|
else {
|
|
978
1035
|
failed++;
|
|
979
1036
|
exitCode = Math.max(exitCode, r.exitCode || 1);
|
|
980
|
-
out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
|
|
1037
|
+
if (!quiet) out(' ✘ ' + s.slug.padEnd(22) + s.name + ' 失败: ' + r.note);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const summary = summarizeInstallResults(results);
|
|
1041
|
+
if (!quiet) {
|
|
1042
|
+
out('');
|
|
1043
|
+
if (opts.dryRun) {
|
|
1044
|
+
out('汇总: 将安装/更新 ' + summary.planned + ' / 已是最新 ' + summary.skip + ' / 失败 ' + failed + '(dry-run 预览)');
|
|
1045
|
+
} else {
|
|
1046
|
+
out('汇总: 更新 ' + summary.ok + ' / 已是最新 ' + summary.skip + ' / 失败 ' + failed);
|
|
981
1047
|
}
|
|
982
1048
|
}
|
|
983
|
-
const ok = results.filter(r => r.status === 'ok').length;
|
|
984
|
-
const skip = results.filter(r => r.status === 'skip').length;
|
|
985
|
-
out('');
|
|
986
|
-
out('汇总: 更新 ' + ok + ' / 已是最新 ' + skip + ' / 失败 ' + failed);
|
|
987
1049
|
if (failed > 0) process.exitCode = exitCode || 1;
|
|
988
|
-
return { failed, exitCode };
|
|
1050
|
+
return { failed, exitCode, results, summary };
|
|
989
1051
|
}
|
|
990
1052
|
|
|
991
1053
|
// ── doctor / rollback ─────────────────────────────────────────────────────
|
|
@@ -1338,86 +1400,23 @@ function printList(opts) {
|
|
|
1338
1400
|
}
|
|
1339
1401
|
|
|
1340
1402
|
function printHelp() {
|
|
1403
|
+
const help = require('../lib/cli-help');
|
|
1341
1404
|
out('yotta-skills(元阁)v' + VERSION + ' —— npx 一次装齐全家技能');
|
|
1342
1405
|
out('');
|
|
1343
1406
|
out('用法:');
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
out(' yotta-skills doctor --dir <path> 只读自检技能目录(可加 --slug / --json)');
|
|
1353
|
-
out(' yotta-skills rollback --dir <path> 回滚最近一次技能安装或更新(--list 查看快照)');
|
|
1354
|
-
out(' yotta-skills --dry-run 预览将安装清单(不联网、不改动)');
|
|
1355
|
-
out(' yotta-skills --inventory 盘点本机已装技能(自研扫描,不依赖任何元技能)');
|
|
1356
|
-
out(' yotta-skills --reindex 重扫注册表(手动触发;install / update 完成后 CLI 自动重扫)');
|
|
1357
|
-
out(' yotta-skills --route "<需求摘要>" 给出场景组合、调用顺序、缺失技能安装建议');
|
|
1358
|
-
out(' yotta-skills usage status 查看本地使用记录开关与计数');
|
|
1359
|
-
out(' yotta-skills usage enable|disable 开启 / 关闭 --route 的结构化使用记录');
|
|
1360
|
-
out(' yotta-skills usage mark --skill <slug> --signal used|named|accepted');
|
|
1361
|
-
out(' yotta-skills hook capabilities 查看宿主六事件能力矩阵');
|
|
1362
|
-
out(' yotta-skills hook evaluate --event <event> --manifest <file> 评估 hook 声明并留证');
|
|
1363
|
-
out(' yotta-skills hook bind --manifest <file> 注册 hook 声明(幂等)');
|
|
1364
|
-
out(' yotta-skills hook unbind <binding-id> 反注册 hook 声明');
|
|
1365
|
-
out(' yotta-skills hub hosts 发现本机已装智能体与技能目录(分类:已核实 / 自动发现 / 桥接)');
|
|
1366
|
-
out(' yotta-skills hub install [skill...] 把元技能装进本机 Hub(清单 27 + 特殊家族 5;单点真源)');
|
|
1367
|
-
out(' yotta-skills hub update 更新 Hub 内的元技能(非元技能不参与,用户自行处理)');
|
|
1368
|
-
out(' yotta-skills hub adopt --scan 只读预演:扫描各宿主现有技能,输出收编候选与冲突');
|
|
1369
|
-
out(' yotta-skills hub adopt --apply 把选中技能复制进 Hub(默认保留原目录;--in-place 原地登记)');
|
|
1370
|
-
out(' yotta-skills hub refresh <slug> --from <path> 手动同步非元阁技能(无统一更新源)');
|
|
1371
|
-
out(' yotta-skills hub link --all 链接到全部已核实宿主;元技能链接时收敛旧副本(回收站 7 天)');
|
|
1372
|
-
out(' yotta-skills hub link --all --include-discovered 显式把自动发现目录一并纳入链接范围');
|
|
1373
|
-
out(' yotta-skills hub link --agent <id> 只链接到一个宿主(未知宿主用 --dir)');
|
|
1374
|
-
out(' yotta-skills hub unlink --all 只删除链接,不动 Hub 真源(fail-closed)');
|
|
1375
|
-
out(' yotta-skills hub status 查看 Hub 技能 / 来源 / 链接 / 宿主');
|
|
1376
|
-
out(' yotta-skills hub doctor 检查断链 / 目标缺失 / slug 不一致 / 单一真源(只读)/ 目录权限');
|
|
1377
|
-
out(' yotta-skills view 启动本机技能枢纽面板(127.0.0.1:8789)');
|
|
1378
|
-
out(' yotta-skills view --port <端口> 指定面板端口(仅本机监听)');
|
|
1407
|
+
for (const group of help.CLI_HELP_MODEL) {
|
|
1408
|
+
for (const cmd of group.commands) {
|
|
1409
|
+
out(' yotta-skills ' + cmd.usage + ' ' + cmd.what);
|
|
1410
|
+
for (const sub of cmd.subcommands || []) {
|
|
1411
|
+
out(' yotta-skills ' + sub.usage + ' ' + sub.what);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1379
1415
|
out('');
|
|
1380
1416
|
out('选项:');
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
out(' --range 跟随同 major 最新 patch(非默认:显式指定后才浮动跟随)');
|
|
1385
|
-
out(' --force 已是最新也重装');
|
|
1386
|
-
out(' --skip-scan 跳过元信装前 scan(装了 yotta-verify 自动启用)');
|
|
1387
|
-
out(' --only <a,b> install / update 只处理指定技能(与位置参数技能列表合并去重)');
|
|
1388
|
-
out(' --domain <name> install / update 只处理指定家族(security / quality / memory / writing / workflow / entry / compliance / education / distribution)');
|
|
1389
|
-
out(' --installed-only update 只维护目标目录已安装的技能(不补装缺失;无匹配时退出码 0)');
|
|
1390
|
-
out(' --npm <path> 指定 npm 可执行文件(默认 npm / npm.cmd)');
|
|
1391
|
-
out(' --python <path> 指定 python 可执行文件(元信 scan 用)');
|
|
1392
|
-
out(' --verify <path> 指定 yotta_verify.py 路径(默认找目标目录已装的元信)');
|
|
1393
|
-
out(' --json inventory / reindex / route / doctor / rollback 时输出 JSON');
|
|
1394
|
-
out(' --slug <slug> doctor / rollback 时只处理指定技能');
|
|
1395
|
-
out(' --route <需求> 静态编排路由(输出组合 / 顺序 / 依据 / 缺失技能建议)');
|
|
1396
|
-
out(' --check update 时仅只读检查更新(联网对 npm 最新版本,不改动;退出码 0/3/1)');
|
|
1397
|
-
out(' --scheduled 与 update --check 合用:后台周检入口(未到期不联网;到期单次检查并写缓存)');
|
|
1398
|
-
out(' --auto update 时检查到家族更新后自动更新(仅 yotta-* 家族,含装前扫描)');
|
|
1399
|
-
out(' --registry <url> npm registry 地址(默认 https://registry.npmjs.org/;YOTTA_SKILLS_REGISTRY 覆盖)');
|
|
1400
|
-
out(' --host <name> hook 适配宿主名(默认 generic;当前已实测 codex)');
|
|
1401
|
-
out(' --event <event> hook 六事件之一(before_start / before_tool / before_install / before_publish / after_milestone / before_send)');
|
|
1402
|
-
out(' --manifest <file> hook evaluate / bind 使用的 skill-manifest.json 路径');
|
|
1403
|
-
out(' --context <json> hook evaluate 的检查结果 JSON(checks / wrapperRegistered / evidence)');
|
|
1404
|
-
out(' --skill <slug> usage mark 的技能 slug');
|
|
1405
|
-
out(' --signal <name> usage mark 的信号:used / named / accepted');
|
|
1406
|
-
out(' --yes usage reset 确认清空');
|
|
1407
|
-
out(' --explain decide-memory 文本报告追加信号明细');
|
|
1408
|
-
out(' --promote decide-memory 只写本地建议文件(不写元忆)');
|
|
1409
|
-
out(' --hub <path> Hub 目录(默认 ~/.yottaskills/hub;YOTTA_SKILLS_HUB 覆盖)');
|
|
1410
|
-
out(' --port <n> view 面板端口(默认 8789;仅 127.0.0.1 监听)');
|
|
1411
|
-
out(' --all hub link / unlink 时作用于全部已核实宿主(默认范围)');
|
|
1412
|
-
out(' --include-discovered hub link / unlink --all 时纳入自动发现目录(默认不链)');
|
|
1413
|
-
out(' --scan hub adopt 只读预演(默认行为)');
|
|
1414
|
-
out(' --apply hub adopt 执行收编');
|
|
1415
|
-
out(' --in-place hub adopt 原地登记,不复制(原目录删除即断链)');
|
|
1416
|
-
out(' --allow-unverified 显式允许未扫描导入(默认 high / critical 阻断)');
|
|
1417
|
-
out(' --project inventory / reindex 时附加扫描当前项目 .agents/skills / .codex/skills');
|
|
1418
|
-
out(' --no-reindex 安装 / 更新后不自动重扫注册表');
|
|
1419
|
-
out(' -h, --help 帮助');
|
|
1420
|
-
out(' -v, --version 版本');
|
|
1417
|
+
for (const opt of help.CLI_GLOBAL_OPTIONS) {
|
|
1418
|
+
out(' ' + (opt.flag + (opt.arg ? ' ' + opt.arg : '')).padEnd(22) + opt.what);
|
|
1419
|
+
}
|
|
1421
1420
|
out('');
|
|
1422
1421
|
out('支持智能体: ' + Object.keys(AGENT_DIRS).length + ' 个已收录映射(本机实际发现用 yotta-skills hub hosts)');
|
|
1423
1422
|
out('依赖: Node.js 18+(必需);npm 与系统 tar 为回退通道(内置拉包 / 内置解包为主);元信 scan 需要 Python 3.8+(--python / YOTTA_SKILLS_PYTHON 可指向宿主自带 Python)。');
|
|
@@ -1938,7 +1937,7 @@ function hubTargetDirs(opts, discovery) {
|
|
|
1938
1937
|
if (opts.dir) {
|
|
1939
1938
|
// Bridge dirs are never link targets, but `unlink --dir` stays available:
|
|
1940
1939
|
// it is the explicit cleanup path for historical out-of-scope links.
|
|
1941
|
-
if (opts.hubAction !== 'unlink' &&
|
|
1940
|
+
if (opts.hubAction !== 'unlink' && opts.hubAction !== 'remove' &&
|
|
1942
1941
|
agentDirsLib.isBridgeOnlyDir(opts.dir, { homeDir: os.homedir(), env: process.env })) {
|
|
1943
1942
|
die('该目录是锁 / 数据桥接目录(XDG_STATE_HOME/skills 或 XDG_DATA_HOME/skills),永不作为链接目标', 2,
|
|
1944
1943
|
'如需链接真实宿主目录,请改用 --agent <id> 或其它 --dir 路径。');
|
|
@@ -1970,27 +1969,89 @@ function hubTargetDirs(opts, discovery) {
|
|
|
1970
1969
|
return targets;
|
|
1971
1970
|
}
|
|
1972
1971
|
|
|
1972
|
+
/**
|
|
1973
|
+
* hub remove 的目标范围:
|
|
1974
|
+
* - --dir / --agent:单个显式目录(桥接目录允许作为显式清理路径)
|
|
1975
|
+
* - 默认:已核实宿主 ∪ 本技能链接台账中记录的目录(覆盖历史 / 死链)
|
|
1976
|
+
* - --include-discovered:在默认范围上纳入自动发现目录
|
|
1977
|
+
*/
|
|
1978
|
+
function hubRemoveTargets(opts, discovery, slug, hubDir) {
|
|
1979
|
+
if (opts.dir || opts.agent) return hubTargetDirs(opts, discovery);
|
|
1980
|
+
const targets = hubTargetDirs({ ...opts, all: true }, discovery);
|
|
1981
|
+
const seen = new Set(targets.map((target) => normalizeTargetKey(target.dir)));
|
|
1982
|
+
const recorded = hubLib.readLinkState(hubDir).links.filter((item) => item.slug === slug);
|
|
1983
|
+
for (const item of recorded) {
|
|
1984
|
+
if (!item.dir) continue;
|
|
1985
|
+
const resolved = path.resolve(item.dir);
|
|
1986
|
+
const key = normalizeTargetKey(resolved);
|
|
1987
|
+
if (seen.has(key)) continue;
|
|
1988
|
+
seen.add(key);
|
|
1989
|
+
targets.push({
|
|
1990
|
+
dir: resolved,
|
|
1991
|
+
agentId: item.agent || null,
|
|
1992
|
+
label: item.label || '台账记录目录',
|
|
1993
|
+
verified: false,
|
|
1994
|
+
bridgeOnly: false,
|
|
1995
|
+
detection: 'ledger',
|
|
1996
|
+
explicit: false,
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
return targets;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const HOST_STATE_LABELS = {
|
|
2003
|
+
available: '可用',
|
|
2004
|
+
orphan: '残留·实体未确认',
|
|
2005
|
+
missing: '未创建',
|
|
2006
|
+
'marker-only': '仅标记',
|
|
2007
|
+
ignored: '已忽略',
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
function hostStateLabel(host) {
|
|
2011
|
+
const state = HOST_STATE_LABELS[host.state] || host.state || '未知';
|
|
2012
|
+
const manual = host.stateSource === 'manual' ? '·手动' : '';
|
|
2013
|
+
return state + manual;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function hostScopeLabel(host) {
|
|
2017
|
+
if (host.bridgeOnly) return '[桥接·不链接]';
|
|
2018
|
+
const discovered = host.verified ? '' : '·自动发现';
|
|
2019
|
+
return '[' + hostStateLabel(host) + discovered + ']';
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function hostStateCounts(hosts) {
|
|
2023
|
+
const counts = { available: 0, orphan: 0, 'marker-only': 0, missing: 0, ignored: 0 };
|
|
2024
|
+
for (const host of hosts || []) {
|
|
2025
|
+
counts[host.state] = (counts[host.state] || 0) + 1;
|
|
2026
|
+
}
|
|
2027
|
+
return counts;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1973
2030
|
function hubHostLines(discovery) {
|
|
1974
2031
|
const lines = [];
|
|
1975
2032
|
const existing = discovery.hosts.filter((host) => host.exists);
|
|
1976
|
-
const
|
|
2033
|
+
const markerOnly = discovery.hosts.filter((host) => !host.exists && host.state === 'marker-only');
|
|
2034
|
+
const missing = discovery.hosts.filter((host) => !host.exists && host.state !== 'marker-only');
|
|
2035
|
+
const shown = new Set();
|
|
1977
2036
|
for (const host of existing) {
|
|
1978
|
-
|
|
1979
|
-
const
|
|
1980
|
-
? ' [桥接·不链接]'
|
|
1981
|
-
: host.verified
|
|
1982
|
-
? ' [已核实]'
|
|
1983
|
-
: host.known ? ' [未核实·默认不链]' : ' [自动发现·默认不链]';
|
|
2037
|
+
shown.add(String(host.label).toLowerCase());
|
|
2038
|
+
const marker = host.bridgeOnly ? '·' : host.state === 'available' ? '✔' : host.state === 'ignored' ? '·' : '△';
|
|
1984
2039
|
lines.push(' ' + marker + ' ' +
|
|
1985
2040
|
String(host.label).padEnd(28) + host.dir.padEnd(64) +
|
|
1986
|
-
String(host.skillCount).padStart(3) + ' 个技能' +
|
|
1987
|
-
|
|
2041
|
+
String(host.skillCount).padStart(3) + ' 个技能 ' +
|
|
2042
|
+
hostScopeLabel(host));
|
|
1988
2043
|
}
|
|
1989
|
-
const
|
|
2044
|
+
for (const host of markerOnly) {
|
|
2045
|
+
shown.add(String(host.label).toLowerCase());
|
|
2046
|
+
lines.push(' △ ' + String(host.label).padEnd(28) + String(host.dir).padEnd(64) +
|
|
2047
|
+
' 0 个技能 ' + hostScopeLabel(host));
|
|
2048
|
+
}
|
|
2049
|
+
const noSkillDir = discovery.installed
|
|
2050
|
+
.filter((item) => !item.hasSkillsDir && !shown.has(String(item.label).toLowerCase()));
|
|
1990
2051
|
for (const item of noSkillDir) {
|
|
1991
2052
|
lines.push(' △ ' + String(item.label).padEnd(28) + '已安装,未发现技能目录 [不适用]');
|
|
1992
2053
|
}
|
|
1993
|
-
return { lines, existing, missing, noSkillDir };
|
|
2054
|
+
return { lines, existing, missing, markerOnly, noSkillDir };
|
|
1994
2055
|
}
|
|
1995
2056
|
|
|
1996
2057
|
function printHubHosts(discovery, json) {
|
|
@@ -1999,16 +2060,18 @@ function printHubHosts(discovery, json) {
|
|
|
1999
2060
|
return;
|
|
2000
2061
|
}
|
|
2001
2062
|
const view = hubHostLines(discovery);
|
|
2002
|
-
const
|
|
2063
|
+
const counts = hostStateCounts(discovery.hosts);
|
|
2064
|
+
const bridgeCount = view.existing.filter((host) => host.bridgeOnly).length;
|
|
2003
2065
|
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 宿主发现(文件系统优先,不读元忆)');
|
|
2004
|
-
out('本机发现技能目录 ' + view.existing.length + '
|
|
2005
|
-
' /
|
|
2006
|
-
' 个 /
|
|
2007
|
-
out('默认链接范围 =
|
|
2066
|
+
out('本机发现技能目录 ' + view.existing.length + ' 个(可用 ' + counts.available +
|
|
2067
|
+
' / 残留 ' + counts.orphan + ' / 桥接 ' + bridgeCount + ' / 已忽略 ' + counts.ignored +
|
|
2068
|
+
')/ 仅标记 ' + counts['marker-only'] + ' 个 / 未创建 ' + counts.missing + ' 个');
|
|
2069
|
+
out('默认链接范围 = 已核实目录(含残留,可清理);--include-discovered 显式纳入自动发现目录;锁 / 数据桥接目录永不链接。');
|
|
2070
|
+
out('残留清理:yotta-skills hub hosts remove <目录> --purge(默认预览,--yes 执行)。');
|
|
2008
2071
|
for (const line of view.lines) out(line);
|
|
2009
2072
|
if (view.missing.length > 0) {
|
|
2010
2073
|
out('');
|
|
2011
|
-
out('
|
|
2074
|
+
out('未创建技能目录 ' + view.missing.length + ' 个(已收录映射,安装后自动出现;yotta-skills hub hosts --state missing 查看)。');
|
|
2012
2075
|
}
|
|
2013
2076
|
}
|
|
2014
2077
|
|
|
@@ -2041,6 +2104,46 @@ function printHubStatus(payload, discovery, json) {
|
|
|
2041
2104
|
printHubHosts(discovery, false);
|
|
2042
2105
|
}
|
|
2043
2106
|
|
|
2107
|
+
function printHubRemove(payload, json) {
|
|
2108
|
+
if (json) {
|
|
2109
|
+
out(JSON.stringify(payload, null, 2));
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— Hub 删除技能');
|
|
2113
|
+
out('Hub: ' + payload.hubDir);
|
|
2114
|
+
out('技能: ' + payload.slug +
|
|
2115
|
+
(payload.hubPresent ? '(Hub 内存在)' : '(Hub 内不存在,执行清理)') +
|
|
2116
|
+
(payload.hubVersion ? ' v' + payload.hubVersion : ''));
|
|
2117
|
+
if (payload.dryRun) out('模式: dry-run(只读预演,不写入)');
|
|
2118
|
+
out('');
|
|
2119
|
+
for (const item of payload.targets || []) {
|
|
2120
|
+
const mark = item.action === 'unlink' ? '✔' : item.action === 'keep' ? '△' : '·';
|
|
2121
|
+
out(' ' + mark + ' ' + item.dir + '(' + item.label + ')');
|
|
2122
|
+
out(' ' + item.note);
|
|
2123
|
+
}
|
|
2124
|
+
if (!(payload.targets || []).length) out(' (没有需要处理的宿主目录)');
|
|
2125
|
+
out('');
|
|
2126
|
+
if (payload.dryRun) {
|
|
2127
|
+
out('预演汇总: 将清理链接 ' + payload.unlinkable + ' 条 / 保留 ' + payload.kept.length +
|
|
2128
|
+
' 项' + (payload.hubPresent ? ' / Hub 目录将移入回收站(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)' : ''));
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (payload.verdict === 'failed') {
|
|
2132
|
+
for (const item of payload.failed || []) out(' ✘ ' + item.dir + ' ' + item.note);
|
|
2133
|
+
out('删除中止:链接或目录处理失败,Hub 真源未删除(已清理的部分可重跑收尾)。');
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (payload.verdict === 'not-found') {
|
|
2137
|
+
out('未找到该技能:Hub / 台账 / 链接均无痕迹(未做任何写入)。');
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
out('汇总: 清理链接 ' + (payload.unlinked || []).length + ' 条 / 保留 ' + payload.kept.length +
|
|
2141
|
+
' 项' + (payload.trashedTo
|
|
2142
|
+
? ' / Hub 目录已移入回收站(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天): ' + payload.trashedTo
|
|
2143
|
+
: ' / Hub 目录不存在(仅清理残留)'));
|
|
2144
|
+
out('审计已写入: ' + hubLib.AUDIT_FILE);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2044
2147
|
function hubScanEngine(hubDir, discovery, opts) {
|
|
2045
2148
|
return hubScanLib.scanEngineForHub(hubDir, discovery, opts);
|
|
2046
2149
|
}
|
|
@@ -2115,16 +2218,64 @@ function runHub(opts) {
|
|
|
2115
2218
|
const discovery = agentDiscoveryLib.discoverHosts({ homeDir: os.homedir(), env: process.env });
|
|
2116
2219
|
|
|
2117
2220
|
if (action === 'hosts' || action === 'discover') {
|
|
2118
|
-
|
|
2221
|
+
const sub = opts.skills[0] || null;
|
|
2222
|
+
if (sub === 'add') { runHostsAdd(opts); return; }
|
|
2223
|
+
if (sub === 'remove') { runHostsRemove(opts); return; }
|
|
2224
|
+
if (sub === 'list') { runHostsList(opts); return; }
|
|
2225
|
+
if (sub === 'mark') { runHostsMark(opts); return; }
|
|
2226
|
+
if (sub) {
|
|
2227
|
+
die('未知 hub hosts 子命令: ' + sub, 2,
|
|
2228
|
+
'支持 add / remove / list / mark;无子命令 = 只读发现。');
|
|
2229
|
+
}
|
|
2230
|
+
const view = opts.state
|
|
2231
|
+
? { ...discovery, hosts: discovery.hosts.filter((host) => host.state === opts.state) }
|
|
2232
|
+
: discovery;
|
|
2233
|
+
printHubHosts(view, opts.json);
|
|
2119
2234
|
return;
|
|
2120
2235
|
}
|
|
2121
2236
|
|
|
2122
2237
|
if (action === 'install' || action === 'update') {
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
'
|
|
2238
|
+
const quiet = Boolean(opts.dryRun && opts.json);
|
|
2239
|
+
if (!quiet) {
|
|
2240
|
+
out('Hub: ' + hubDir);
|
|
2241
|
+
out('范围: 全家清单 ' + MANIFEST.length + ' + 特殊家族 ' + hubLib.HUB_FAMILY_EXTRAS.length +
|
|
2242
|
+
'(特殊家族跟随各自 npm latest;非元技能不参与更新)');
|
|
2243
|
+
if (opts.dryRun) out('模式: dry-run(只读预演;无网络、无写入)');
|
|
2244
|
+
}
|
|
2126
2245
|
const hubOpts = { ...opts, hubScope: true };
|
|
2127
2246
|
const result = action === 'install' ? runInstall(hubOpts, hubDir) : runUpdate(hubOpts, hubDir);
|
|
2247
|
+
if (opts.dryRun) {
|
|
2248
|
+
// 0.29.0 D1:预览不更新 Hub 台账(无写入)。
|
|
2249
|
+
if (quiet) {
|
|
2250
|
+
out(JSON.stringify({
|
|
2251
|
+
action,
|
|
2252
|
+
hubDir,
|
|
2253
|
+
dryRun: true,
|
|
2254
|
+
scope: { manifest: MANIFEST.length, extras: hubLib.HUB_FAMILY_EXTRAS.length },
|
|
2255
|
+
skills: (result.results || []).map(function (r) {
|
|
2256
|
+
const isPlanned = r.status === 'planned';
|
|
2257
|
+
const isSkip = r.status === 'skip';
|
|
2258
|
+
return {
|
|
2259
|
+
slug: r.skill.slug,
|
|
2260
|
+
name: r.skill.name,
|
|
2261
|
+
pkg: r.skill.pkg,
|
|
2262
|
+
planned: isPlanned ? r.planned : (isSkip ? 'skip' : 'fail'),
|
|
2263
|
+
version: isPlanned ? r.version : (r.version || null),
|
|
2264
|
+
installedVersion: isPlanned ? (r.installedVersion || null) : (isSkip ? r.version : null),
|
|
2265
|
+
latestUnresolved: Boolean(r.latestUnresolved),
|
|
2266
|
+
spec: specOf(r.skill, opts.pin),
|
|
2267
|
+
note: r.note || null,
|
|
2268
|
+
};
|
|
2269
|
+
}),
|
|
2270
|
+
summary: result.summary || { planned: 0, skip: 0, failed: 0 },
|
|
2271
|
+
}, null, 2));
|
|
2272
|
+
} else {
|
|
2273
|
+
out('');
|
|
2274
|
+
out('(dry-run 未执行任何下载 / 写入;Hub 台账未更新)');
|
|
2275
|
+
}
|
|
2276
|
+
if (result.failed > 0) process.exitCode = result.exitCode || 1;
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2128
2279
|
const synced = hubLib.syncHubState(hubDir, { manifest: MANIFEST });
|
|
2129
2280
|
const count = Object.values(synced.state.skills).filter((item) => item.status === 'present').length;
|
|
2130
2281
|
const external = Object.values(synced.state.skills)
|
|
@@ -2266,6 +2417,43 @@ function runHub(opts) {
|
|
|
2266
2417
|
return;
|
|
2267
2418
|
}
|
|
2268
2419
|
|
|
2420
|
+
if (action === 'remove') {
|
|
2421
|
+
if (opts.skills.length !== 1) {
|
|
2422
|
+
die('hub remove 需要一个且仅一个技能 slug', 2,
|
|
2423
|
+
'示例:yotta-skills hub remove my-skill;只读预演加 --dry-run。');
|
|
2424
|
+
}
|
|
2425
|
+
const slug = opts.skills[0];
|
|
2426
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
2427
|
+
die('hub remove slug 格式非法: ' + slug, 2, '只允许小写字母、数字和连字符,例如 my-skill。');
|
|
2428
|
+
}
|
|
2429
|
+
if (opts.dir || opts.agent) {
|
|
2430
|
+
let hubEntryPresent = false;
|
|
2431
|
+
try {
|
|
2432
|
+
const stat = fs.lstatSync(path.join(hubDir, slug));
|
|
2433
|
+
hubEntryPresent = stat.isDirectory() || stat.isSymbolicLink();
|
|
2434
|
+
} catch (_) { hubEntryPresent = false; }
|
|
2435
|
+
if (hubEntryPresent) {
|
|
2436
|
+
die('hub remove 的 --agent / --dir 只用于清理 Hub 目标已缺失的死链', 2,
|
|
2437
|
+
'删除整个 Hub 技能请用全范围(不加 --agent/--dir);只解除指定宿主的链接请用 hub unlink <slug> --agent <id> 或 --dir <目录>。');
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
const targets = hubRemoveTargets(opts, discovery, slug, hubDir);
|
|
2441
|
+
if (!opts.json) {
|
|
2442
|
+
out('范围: ' + targets.length + ' 个宿主目录' +
|
|
2443
|
+
(opts.dir || opts.agent ? '(显式指定)' : '(已核实宿主 ∪ 本技能链接台账)'));
|
|
2444
|
+
}
|
|
2445
|
+
const payload = hubLib.removeSkills({
|
|
2446
|
+
hubDir,
|
|
2447
|
+
slug,
|
|
2448
|
+
targets,
|
|
2449
|
+
dryRun: opts.dryRun,
|
|
2450
|
+
manifest: MANIFEST,
|
|
2451
|
+
});
|
|
2452
|
+
printHubRemove(payload, opts.json);
|
|
2453
|
+
if (payload.exitCode) process.exitCode = payload.exitCode;
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2269
2457
|
if (action === 'status') {
|
|
2270
2458
|
const payload = hubLib.status({ hubDir, manifest: MANIFEST });
|
|
2271
2459
|
printHubStatus(payload, discovery, opts.json);
|
|
@@ -2287,7 +2475,251 @@ function runHub(opts) {
|
|
|
2287
2475
|
}
|
|
2288
2476
|
|
|
2289
2477
|
die('未知 hub 子命令: ' + action, 2,
|
|
2290
|
-
'支持 install / update / adopt / refresh / link / unlink / status / hosts / doctor。');
|
|
2478
|
+
'支持 install / update / adopt / refresh / link / unlink / remove / status / hosts / doctor。');
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// ── hub hosts add / remove / list / mark(0.29.0 F2/F3 注册表与手动标记) ─────
|
|
2482
|
+
function hostRegistryOptions(opts) {
|
|
2483
|
+
return { homeDir: os.homedir(), env: process.env, hub: opts.hub };
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
function auditHostEvent(opts, entry) {
|
|
2487
|
+
const hubDir = hubLib.resolveHubDir(opts);
|
|
2488
|
+
if (!fs.existsSync(hubDir)) return;
|
|
2489
|
+
try {
|
|
2490
|
+
hubLib.appendAudit(hubDir, entry);
|
|
2491
|
+
} catch (_) { /* 审计失败不阻断注册 / 清理主流程 */ }
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/** hub hosts 子命令的原始位置参数(保留路径大小写;slug 版本已小写化)。 */
|
|
2495
|
+
function hostsSubcommandArg(opts, index) {
|
|
2496
|
+
if (opts.rawRest && opts.rawRest.length > index) return opts.rawRest[index];
|
|
2497
|
+
return opts.skills[index] || null;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
function runHostsAdd(opts) {
|
|
2501
|
+
const dir = hostsSubcommandArg(opts, 2);
|
|
2502
|
+
if (!dir) {
|
|
2503
|
+
die('hub hosts add 需要目录', 2,
|
|
2504
|
+
'示例:yotta-skills hub hosts add "<技能目录>" --label "我的宿主"。');
|
|
2505
|
+
}
|
|
2506
|
+
const registryOpts = hostRegistryOptions(opts);
|
|
2507
|
+
const result = hostsRegistryLib.addHost(registryOpts, { dir, label: opts.label, agentId: opts.id });
|
|
2508
|
+
if (!result.ok) {
|
|
2509
|
+
die(result.error, 2, '确认目录存在且不是 Hub / 锁桥接目录后重试。');
|
|
2510
|
+
}
|
|
2511
|
+
auditHostEvent(opts, {
|
|
2512
|
+
event: 'hosts.add',
|
|
2513
|
+
dir: result.entry.dir,
|
|
2514
|
+
label: result.entry.label,
|
|
2515
|
+
agentId: result.entry.agentId,
|
|
2516
|
+
});
|
|
2517
|
+
if (opts.json) {
|
|
2518
|
+
out(JSON.stringify({
|
|
2519
|
+
action: 'hosts.add',
|
|
2520
|
+
entry: result.entry,
|
|
2521
|
+
registryPath: hostsRegistryLib.hostsRegistryPath(registryOpts),
|
|
2522
|
+
}, null, 2));
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
out('✔ 已注册自定义宿主: ' + result.entry.label);
|
|
2526
|
+
out(' 目录: ' + result.entry.dir);
|
|
2527
|
+
out(' 注册表: ' + hostsRegistryLib.hostsRegistryPath(registryOpts));
|
|
2528
|
+
out(' 已纳入: hub link --all / hub adopt --scan / 面板宿主矩阵(不创建目录、不改宿主配置)。');
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function runHostsList(opts) {
|
|
2532
|
+
const registryOpts = hostRegistryOptions(opts);
|
|
2533
|
+
const registry = hostsRegistryLib.readHostsRegistry(registryOpts);
|
|
2534
|
+
if (opts.json) {
|
|
2535
|
+
out(JSON.stringify({
|
|
2536
|
+
action: 'hosts.list',
|
|
2537
|
+
registryPath: hostsRegistryLib.hostsRegistryPath(registryOpts),
|
|
2538
|
+
hosts: registry.hosts,
|
|
2539
|
+
}, null, 2));
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 自定义宿主注册表');
|
|
2543
|
+
out('注册表: ' + hostsRegistryLib.hostsRegistryPath(registryOpts));
|
|
2544
|
+
if (registry.hosts.length === 0) {
|
|
2545
|
+
out('(空;用 yotta-skills hub hosts add <目录> 注册)');
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
for (const item of registry.hosts) {
|
|
2549
|
+
out(' ' + String(item.label).padEnd(28) + item.dir +
|
|
2550
|
+
(item.manualState ? ' [手动: ' + item.manualState + ']' : ''));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
function runHostsRemove(opts) {
|
|
2555
|
+
const dir = hostsSubcommandArg(opts, 2);
|
|
2556
|
+
if (opts.purge) {
|
|
2557
|
+
runHostsPurge(opts, dir);
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
if (!dir && !opts.id) {
|
|
2561
|
+
die('hub hosts remove 需要 <目录> 或 --id <agentId>', 2,
|
|
2562
|
+
'示例:yotta-skills hub hosts remove "<技能目录>";残留清理加 --purge(默认预览)。');
|
|
2563
|
+
}
|
|
2564
|
+
const registryOpts = hostRegistryOptions(opts);
|
|
2565
|
+
const result = hostsRegistryLib.removeHost(registryOpts, { dir, id: opts.id });
|
|
2566
|
+
if (!result.ok) {
|
|
2567
|
+
die(result.error, 2, '示例:yotta-skills hub hosts list 查看已注册项。');
|
|
2568
|
+
}
|
|
2569
|
+
auditHostEvent(opts, {
|
|
2570
|
+
event: 'hosts.remove',
|
|
2571
|
+
dir: result.removed.dir,
|
|
2572
|
+
label: result.removed.label,
|
|
2573
|
+
agentId: result.removed.agentId,
|
|
2574
|
+
});
|
|
2575
|
+
if (opts.json) {
|
|
2576
|
+
out(JSON.stringify({ action: 'hosts.remove', removed: result.removed }, null, 2));
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
out('✔ 已移除注册(目录未删除): ' + result.removed.dir);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function runHostsMark(opts) {
|
|
2583
|
+
const dir = hostsSubcommandArg(opts, 2);
|
|
2584
|
+
if (!dir) {
|
|
2585
|
+
die('hub hosts mark 需要目录', 2,
|
|
2586
|
+
'示例:yotta-skills hub hosts mark "<技能目录>" --state orphan。');
|
|
2587
|
+
}
|
|
2588
|
+
const state = opts.state;
|
|
2589
|
+
if (!state) {
|
|
2590
|
+
die('hub hosts mark 需要 --state <available|orphan|ignored>', 2,
|
|
2591
|
+
'可用(available)/ 残留(orphan)/ 忽略(ignored)。');
|
|
2592
|
+
}
|
|
2593
|
+
const registryOpts = hostRegistryOptions(opts);
|
|
2594
|
+
const result = hostsRegistryLib.markHost(registryOpts, { dir, state });
|
|
2595
|
+
if (!result.ok) {
|
|
2596
|
+
die(result.error, 2, '手动标记与注册表共用存储(hosts.json)。');
|
|
2597
|
+
}
|
|
2598
|
+
auditHostEvent(opts, { event: 'hosts.mark', dir: result.entry.dir, state });
|
|
2599
|
+
if (opts.json) {
|
|
2600
|
+
out(JSON.stringify({ action: 'hosts.mark', entry: result.entry }, null, 2));
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
out('✔ 已手动标记: ' + result.entry.dir + ' -> ' + state);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* 残留目录清理(F3):默认预览;--yes 执行。核心逻辑在 lib/host-purge.js
|
|
2608
|
+
* (CLI 与面板共用,fail-closed)。
|
|
2609
|
+
*/
|
|
2610
|
+
function runHostsPurge(opts, dir) {
|
|
2611
|
+
if (!dir) {
|
|
2612
|
+
die('hub hosts remove --purge 需要显式目录', 2,
|
|
2613
|
+
'示例:yotta-skills hub hosts remove "<技能目录>" --purge(默认预览;确认后加 --yes 执行)。');
|
|
2614
|
+
}
|
|
2615
|
+
const purgeOptions = {
|
|
2616
|
+
hubDir: hubLib.resolveHubDir(opts),
|
|
2617
|
+
dir,
|
|
2618
|
+
homeDir: os.homedir(),
|
|
2619
|
+
env: process.env,
|
|
2620
|
+
hub: opts.hub,
|
|
2621
|
+
};
|
|
2622
|
+
const plan = hostPurgeLib.planHostPurge(purgeOptions);
|
|
2623
|
+
if (!plan.ok) {
|
|
2624
|
+
die(plan.error, 2, '只允许清理残留目录;如确认要清理,先运行 yotta-skills hub hosts mark "<目录>" --state orphan。');
|
|
2625
|
+
}
|
|
2626
|
+
if (!opts.yes) {
|
|
2627
|
+
const payload = {
|
|
2628
|
+
action: 'hosts.purge',
|
|
2629
|
+
dryRun: true,
|
|
2630
|
+
dir: plan.target,
|
|
2631
|
+
state: plan.state,
|
|
2632
|
+
hubDir: plan.hubDir,
|
|
2633
|
+
unlinkCount: plan.linkSlugs.length,
|
|
2634
|
+
keptLinks: plan.keptLinks,
|
|
2635
|
+
keptEntries: plan.keptEntries,
|
|
2636
|
+
trashedTo: plan.trashPath,
|
|
2637
|
+
note: '预览未执行任何变更;确认后加 --yes 执行。',
|
|
2638
|
+
};
|
|
2639
|
+
if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
|
|
2640
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 残留清理(预览)');
|
|
2641
|
+
out('目录: ' + plan.target);
|
|
2642
|
+
out('状态: 残留(实体未确认)');
|
|
2643
|
+
out('将解除 Hub 链接: ' + plan.linkSlugs.length + ' 条(非 Hub 链接 ' + plan.keptLinks + ' / 非链接内容 ' + plan.keptEntries + ' 保留)');
|
|
2644
|
+
out('将移入回收站: ' + plan.trashPath + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2645
|
+
out('(预览未执行任何变更;确认后加 --yes 执行)');
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
const result = hostPurgeLib.applyHostPurge(purgeOptions);
|
|
2649
|
+
if (!result.ok) {
|
|
2650
|
+
die(result.error, 2, '残留清理失败;请检查目录状态后重试。');
|
|
2651
|
+
}
|
|
2652
|
+
const payload = {
|
|
2653
|
+
action: 'hosts.purge',
|
|
2654
|
+
dryRun: false,
|
|
2655
|
+
dir: result.dir,
|
|
2656
|
+
state: result.state,
|
|
2657
|
+
hubDir: plan.hubDir,
|
|
2658
|
+
unlinkCount: result.unlinkCount,
|
|
2659
|
+
keptLinks: result.keptLinks,
|
|
2660
|
+
keptEntries: result.keptEntries,
|
|
2661
|
+
trashedTo: result.trashedTo,
|
|
2662
|
+
restore: '把目录移回原位置即可恢复;回收站保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天。',
|
|
2663
|
+
};
|
|
2664
|
+
if (opts.json) { out(JSON.stringify(payload, null, 2)); return; }
|
|
2665
|
+
out('✔ 已清理残留目录: ' + result.dir);
|
|
2666
|
+
out(' 解除 Hub 链接: ' + result.unlinkCount + ' 条(非 Hub 链接 ' + result.keptLinks + ' / 非链接内容 ' + result.keptEntries + ' 随目录保留在回收站)');
|
|
2667
|
+
out(' 回收站: ' + result.trashedTo + '(保留 ' + hubLib.TRASH_RETENTION_DAYS + ' 天)');
|
|
2668
|
+
out(' 恢复: 把目录移回原位置即可。');
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// ── install-self / where:元阁独立安装与位置查看(0.29.0 F1) ───────────────
|
|
2672
|
+
function runSelfInstall(opts) {
|
|
2673
|
+
const result = selfInstallLib.installSelf({
|
|
2674
|
+
dir: opts.dir,
|
|
2675
|
+
dryRun: opts.dryRun,
|
|
2676
|
+
force: opts.force,
|
|
2677
|
+
hub: opts.hub,
|
|
2678
|
+
homeDir: os.homedir(),
|
|
2679
|
+
env: process.env,
|
|
2680
|
+
});
|
|
2681
|
+
if (!result.ok) {
|
|
2682
|
+
die(result.error, result.exitCode || 2,
|
|
2683
|
+
result.hint || '独立安装默认位置 ~/.yottaskills/yotta-skills;用 --dir 指定位置。');
|
|
2684
|
+
}
|
|
2685
|
+
if (opts.json) {
|
|
2686
|
+
out(JSON.stringify(result, null, 2));
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 独立安装');
|
|
2690
|
+
out('目标: ' + result.dir);
|
|
2691
|
+
if (result.dryRun) {
|
|
2692
|
+
out('将写入: ' + result.files.join(' / '));
|
|
2693
|
+
out('登记: ' + result.registry);
|
|
2694
|
+
out('(dry-run 未执行任何写入)');
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
out('✔ 已安装运行件: ' + result.files.join(' / '));
|
|
2698
|
+
out('登记: ' + result.registry);
|
|
2699
|
+
out('提示: 直接用 node ' + path.join(result.dir, 'bin', 'yotta-skills.js') + ' 运行;不写宿主配置、不建全局 shim。');
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
function runWhere(opts) {
|
|
2703
|
+
const info = selfInstallLib.whereInfo({
|
|
2704
|
+
hub: opts.hub,
|
|
2705
|
+
homeDir: os.homedir(),
|
|
2706
|
+
env: process.env,
|
|
2707
|
+
});
|
|
2708
|
+
if (opts.json) {
|
|
2709
|
+
out(JSON.stringify(info, null, 2));
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
out('yotta-skills(元阁)v' + VERSION + ' —— 位置');
|
|
2713
|
+
out('运行中 CLI: ' + (info.running || '未知'));
|
|
2714
|
+
if (info.installs.length === 0) {
|
|
2715
|
+
out('独立安装: 未独立安装(经 npx / 宿主副本运行)');
|
|
2716
|
+
} else {
|
|
2717
|
+
for (const item of info.installs) {
|
|
2718
|
+
out('独立安装: ' + item.dir + '(v' + (item.version || '?') + (item.current ? ',当前运行项' : '') + ')');
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
out('默认安装位置: ' + info.defaultDir);
|
|
2722
|
+
out('Hub 真源: ' + info.hubDir);
|
|
2291
2723
|
}
|
|
2292
2724
|
|
|
2293
2725
|
// ── view:本机技能枢纽面板(127.0.0.1,只读 API + 预览确认动作) ───────────
|
|
@@ -2365,6 +2797,14 @@ function main() {
|
|
|
2365
2797
|
runHub(opts);
|
|
2366
2798
|
return;
|
|
2367
2799
|
}
|
|
2800
|
+
if (command === 'install-self') {
|
|
2801
|
+
runSelfInstall(opts);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
if (command === 'where') {
|
|
2805
|
+
runWhere(opts);
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2368
2808
|
if (command === 'rollback' && opts.list) {
|
|
2369
2809
|
runRollback(opts, null);
|
|
2370
2810
|
return;
|
|
@@ -2372,7 +2812,8 @@ function main() {
|
|
|
2372
2812
|
let dest = resolveTargetDir(opts);
|
|
2373
2813
|
if (!dest && !opts.dryRun) dest = detectProjectDir();
|
|
2374
2814
|
if (!dest && (command === 'install' || command === 'doctor' || command === 'rollback') && !opts.dryRun) {
|
|
2375
|
-
die('未指定目标:请用 --agent <name> 或 --dir <path>(当前目录未检测到项目级技能目录)。', 4,
|
|
2815
|
+
die('未指定目标:请用 --agent <name> 或 --dir <path>(当前目录未检测到项目级技能目录)。', 4,
|
|
2816
|
+
'装到本机 Hub 用 hub install;直装到某智能体用 --agent / --dir;独立安装元阁用 install-self。');
|
|
2376
2817
|
}
|
|
2377
2818
|
|
|
2378
2819
|
if (opts.dryRun) {
|