From 056f2d4ea3fa46354e4348d4639f49d7ae0aa67a Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 16 Oct 2026 23:23:40 +0800 Subject: [PATCH] feat(card): show cached netx skills per capability group Co-Authored-By: Claude Opus 5.5 --- .release-build.json | 26 +++--- lib/client.js | 43 +++++++++- lib/index.js | 141 ++++++++++++++++++++------------- src/client/NetxopsCard.tsx | 26 +++++- src/client/locales.ts | 9 +++ src/client/mcp-catalog-view.ts | 24 ++++++ src/index.ts | 48 ++++++----- src/netx/skills-cache.ts | 47 +++++++++++ test/group-skills.test.mjs | 17 ++++ 9 files changed, 285 insertions(+), 96 deletions(-) diff --git a/.release-build.json b/.release-build.json index 896bf7b..c0a0772 100644 --- a/.release-build.json +++ b/.release-build.json @@ -29,7 +29,7 @@ "src/netx/mcp-store.ts": "002a5d432d4532ea0ed07f00801eea9ca8cb84d6ebcb347311967e13007fde32", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } @@ -62,7 +62,7 @@ "src/netx/mcp-store.ts": "002a5d432d4532ea0ed07f00801eea9ca8cb84d6ebcb347311967e13007fde32", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } @@ -95,7 +95,7 @@ "src/netx/mcp-store.ts": "002a5d432d4532ea0ed07f00801eea9ca8cb84d6ebcb347311967e13007fde32", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } @@ -128,7 +128,7 @@ "src/netx/mcp-store.ts": "002a5d432d4532ea0ed07f00801eea9ca8cb84d6ebcb347311967e13007fde32", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } @@ -161,7 +161,7 @@ "src/netx/mcp-store.ts": "002a5d432d4532ea0ed07f00801eea9ca8cb84d6ebcb347311967e13007fde32", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } @@ -194,27 +194,27 @@ "src/netx/mcp-store.ts": "002a5d432d4532ea0ed07f00801eea9ca8cb84d6ebcb347311967e13007fde32", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/client.js": { - "output": "16f3328f3d747ebcfc02accbc09419868db10618569fab7493e3875ada9d6b45", + "output": "76d73a9e8354e55c27d60c90d17206b7d9eb096829a64cbfc15ab1152c7db49b", "inputs": { "package.json": "5132d4dd66628c59e1b495b5a75fc6c29a30c220a6c112059b4c3b5983d3cbb9", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-client.mjs": "bbb37a7d5024772f35824a4a3b5b9bc75fc670da391151e35a3811a677e23fd1", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", - "src/client/NetxopsCard.tsx": "ab8c18fdfd5550b89c8d3b0df46e0448abc27b824a6cd644e9e364fdaceafd46", + "src/client/NetxopsCard.tsx": "51d85f150fd13fc5377de29a4e80b992e860514e0700884218f9f9c7bdcd4cc1", "src/client/alarm-push-status-view.ts": "9ad84e4a691a6c22de4d85c45047251716929c63cada362205db98428a810a31", "src/client/card-form.ts": "00ec10c453fffae8beac4a35a13299514c120c913100a6e4536e7a00d072b8d9", "src/client/controller.ts": "d3353feda6f6b004dd094b1fc90d743003b55ce5a9d0b46c8fb9c1eb33925cc1", "src/client/im-delivery-catalog.ts": "faf18d4a8fdcf30033b6d905f156c6fc953d214f8d734417f3bed783994d6117", "src/client/index.ts": "1f73ae8e8c02e13b47c5679869359444485b4621eca20a15c632289f22e5d760", "src/client/kb-status-view.ts": "458d1dfd9c0f93e6cdd06780c0ad813de54c3aa952fcdb63758a616ab43ffb10", - "src/client/locales.ts": "5acc66e429bfd9e91c628e6d825582d9fa2f4bebcc6d114384ca5628f5deb61f", - "src/client/mcp-catalog-view.ts": "68a79f1de44760c1e08edad53e1df47eb82124924b9bb680bb430db3c7edb89a", + "src/client/locales.ts": "b9aa9cac558119c3a81e773dc3b49378f0c37fb2220696a484a92bf19aabb5d6", + "src/client/mcp-catalog-view.ts": "3066ba1fd56b19e099e1f58ba9699a4d17de9f5733e27a4f4da0c4f58809a545", "src/client/sessions-export-view.ts": "3397b081318c23d9d816596566e19101df7234390a698a07481c7c1a019a66fd", "src/client/settings-form.ts": "0318749c9afd049982a60eee7b2ca4a0a2cdcd4f574f9c87616f2ccbe39779eb", "src/client/snapshot-store.ts": "25fdcae95490a9d7f1f520ec90981cefb4fdd576b340717e9087f1ca82c6563d", @@ -231,14 +231,14 @@ } }, "lib/index.js": { - "output": "b3712d7ccff953a8fa1ae0b0339ab3b247d65ce21d4690817d15c168d2876275", + "output": "515aafc5a26d8680cedbe970751b5f565e098c8f691014fb0f9a1fe9edd6f82f", "inputs": { "package.json": "5132d4dd66628c59e1b495b5a75fc6c29a30c220a6c112059b4c3b5983d3cbb9", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "src/agent-preset-install.ts": "1f397bb8e1c9a3f13cb7e4c442c3d13c42fce94fbc4eb1d90b85a0485bda48bc", - "src/index.ts": "d5f7417240c56ec24bf9b456be26749da7130a60d84659f7dd1941965682c086", + "src/index.ts": "f583927e67045d83362930d8c416d540c1d8b08b18b2ad78e2a3d72ea03df559", "src/netx/alarm-dispatch.ts": "9f476f1c620e40d3e2017554ec2fa8a1848f75d7e77e7c9ee5ee0464e970ba62", "src/netx/alarm-im.ts": "a75b37e379500cbb90f1f4b81d448d45a97fd9e744e507608910f510480cffaf", "src/netx/alarm-push-status.ts": "21f0efa33ea6f21e3ede35b4accbc6c0c4c78fea23631f1c5b80e6e9aef8b82f", @@ -271,7 +271,7 @@ "src/netx/preset-publication.ts": "22e357cb58bf72cb0d269246f9e3e083269feb1706a9c4f152eb042330c9e967", "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", "src/netx/session-export.ts": "8e6e9a3094045901563366fac3d6dc74d9bfd78bbff853358d6500955d3dcff6", - "src/netx/skills-cache.ts": "3b5c01d3f2b914ca1275b7eb7b29a85738f05295250667c66c27a00650300409", + "src/netx/skills-cache.ts": "10957061256cec6b381c5604745d905146abe5da1b418055414166879bb90094", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139", "src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5" diff --git a/lib/client.js b/lib/client.js index 64d8086..48d9746 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1435,6 +1435,14 @@ function CapabilityGroupBlock(props) { ] }), props.targets, + props.skills ? /* @__PURE__ */ jsx_runtime.jsxs("p", { + className: "dsh-nx-hint", + children: [ + props.skillsLabel ?? "Skill", + ":", + props.skills.length === 0 ? props.skillsNoneLabel ?? "" : props.skills.map((skill) => `${skill.name}(${props.skillFilesLabel?.(skill.files) ?? skill.files})`).join("、") + ] + }) : null, props.tools ? /* @__PURE__ */ jsx_runtime.jsxs("details", { className: "dsh-nx-toolList", children: [ @@ -1806,7 +1814,12 @@ function NetxopsCard(props) { bizMonitor: catalogTools.filter((tool) => tool.group === "bizMonitor") }; const disabledSet = new Set(parseDisabledTools(state.disabledTools.text)); + const catalogSkills = state.mcpCatalog?.skills ?? []; + const skillsOf = (group) => catalogSkills.filter((skill) => skill.group === group); const toolProps = { + skillsLabel: t("groupSkills"), + skillsNoneLabel: t("groupSkillsNone"), + skillFilesLabel: (count) => t("skillFiles").replace("{count}", String(count)), disabledTools: disabledSet, writeBadge: t("toolWriteBadge"), emptyLabel: t("toolListEmpty"), @@ -1820,6 +1833,7 @@ function NetxopsCard(props) { title: t("groupOps"), toolCount: byGroup.ops.length, tools: byGroup.ops, + skills: skillsOf("ops"), targets: targets("groupOpsPresets", state.groupOpsPresets), inPresetLabel: t("groupInPreset"), publicLabel: t("groupPublic"), @@ -1838,6 +1852,7 @@ function NetxopsCard(props) { title: t("groupTopology"), toolCount: byGroup.topology.length, tools: byGroup.topology, + skills: skillsOf("topology"), targets: targets("groupTopologyPresets", state.groupTopologyPresets), inPresetLabel: t("groupInPreset"), publicLabel: t("groupPublic"), @@ -1856,6 +1871,7 @@ function NetxopsCard(props) { title: t("groupBizMonitor"), toolCount: byGroup.bizMonitor.length, tools: byGroup.bizMonitor, + skills: skillsOf("bizMonitor"), targets: targets("groupBizMonitorPresets", state.groupBizMonitorPresets), inPresetLabel: t("groupInPreset"), publicLabel: t("groupPublic"), @@ -2454,7 +2470,9 @@ var EMPTY3 = { error: "", listedAt: null, fromCache: false, - tools: [] + tools: [], + skills: [], + skillsListedAt: null }; function asMcpCatalogView(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) @@ -2478,13 +2496,28 @@ function asMcpCatalogView(value) { }); } } + const skills = []; + if (Array.isArray(row.skills)) { + for (const raw of row.skills) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const s = raw; + const group = s.group === "ops" || s.group === "topology" || s.group === "bizMonitor" ? s.group : null; + const name = typeof s.name === "string" ? s.name : ""; + if (!group || !name) + continue; + skills.push({ group, name, files: typeof s.files === "number" ? s.files : 0 }); + } + } return { phase: typeof row.phase === "string" ? row.phase : "idle", mcpUrl: typeof row.mcpUrl === "string" ? row.mcpUrl : "", error: typeof row.error === "string" ? row.error : "", listedAt: typeof row.listedAt === "string" ? row.listedAt : null, fromCache: row.fromCache === true, - tools + tools, + skills, + skillsListedAt: typeof row.skillsListedAt === "string" ? row.skillsListedAt : null }; } async function fetchMcpCatalog(call, signal) { @@ -3243,6 +3276,9 @@ var en = { readOnlyOnly: "Read-only tools only", toolWriteBadge: "write", toolListEmpty: "No tools yet", + groupSkills: "Skills", + groupSkillsNone: "None pulled from netx", + skillFiles: "{count} files", sessionsExportCount: "{count} sessions", sessionsExportUnavailable: "Export unavailable", sessionsExportNoPersistence: "Session store unavailable.", @@ -3336,6 +3372,9 @@ var zh = { readOnlyOnly: "只允许只读工具", toolWriteBadge: "写", toolListEmpty: "暂无工具清单", + groupSkills: "Skill", + groupSkillsNone: "未从 netx 拉到", + skillFiles: "{count} 个文件", sessionsExportCount: "{count} 个会话", sessionsExportUnavailable: "无法导出", sessionsExportNoPersistence: "会话存储不可用。", diff --git a/lib/index.js b/lib/index.js index 5bfccf6..361e3c1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,7 +2,7 @@ import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/index.ts -import { readFileSync as readFileSync9 } from "node:fs"; +import { readFileSync as readFileSync10 } from "node:fs"; import { dirname as dirname8, join as join14 } from "node:path"; import { fileURLToPath as fileURLToPath4 } from "node:url"; import z from "@deepseek-ai/schemastery"; @@ -1391,7 +1391,9 @@ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, + readFileSync as readFileSync3, rmSync, + statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs"; import { homedir as homedir3 } from "node:os"; @@ -1449,6 +1451,40 @@ function replaceSkillsCache(apiUrl, files) { `, "utf8"); return root; } +function countFiles(dir) { + let n = 0; + for (const entry of readdirSync(dir)) { + const full = join3(dir, entry); + n += statSync2(full).isDirectory() ? countFiles(full) : 1; + } + return n; +} +function listCachedSkills(apiUrl) { + const root = skillsCacheDir(apiUrl); + const skills = []; + let listedAt = null; + if (!apiUrl.trim() || !existsSync3(root)) + return { listedAt, skills }; + try { + const meta = JSON.parse(readFileSync3(join3(root, ".meta.json"), "utf8")); + if (typeof meta.listedAt === "string") + listedAt = meta.listedAt; + } catch {} + for (const group of CAPABILITY_GROUP_IDS) { + const groupDir = join3(root, SKILL_DIR_BY_GROUP[group]); + if (!existsSync3(groupDir)) + continue; + try { + for (const name of readdirSync(groupDir).sort()) { + const dir = join3(groupDir, name); + if (!statSync2(dir).isDirectory() || !existsSync3(join3(dir, "SKILL.md"))) + continue; + skills.push({ group, name, files: countFiles(dir) }); + } + } catch {} + } + return { listedAt, skills }; +} // src/netx/group-skills.ts function parseFrontmatter(raw) { @@ -1659,11 +1695,11 @@ Use only tools present in this session. Dual-unit/orbit/polish recipes require s } // src/netx/kb-context-skill.ts -import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync4 } from "node:fs"; +import { readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync5 } from "node:fs"; import { join as join7 } from "node:path"; // src/netx/kb-manifest.ts -import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs"; +import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs"; import { basename, dirname as dirname3, join as join5, resolve } from "node:path"; var EMPTY_CONTENT = { hasRegions: false, @@ -1710,7 +1746,7 @@ function findManifest(kbRoot, maxDepth = 3) { } let rootStat; try { - rootStat = statSync2(root); + rootStat = statSync3(root); } catch { return { paths: [], error: `kbRoot not found: ${root}` }; } @@ -1720,7 +1756,7 @@ function findManifest(kbRoot, maxDepth = 3) { const direct = join5(root, "MANIFEST.json"); if (existsSync4(direct)) { try { - if (statSync2(direct).isFile()) + if (statSync3(direct).isFile()) return { paths: [direct] }; } catch {} } @@ -1731,7 +1767,7 @@ function findManifest(kbRoot, maxDepth = 3) { const candidate = join5(dir, "MANIFEST.json"); if (existsSync4(candidate)) { try { - if (statSync2(candidate).isFile()) + if (statSync3(candidate).isFile()) found.push(candidate); } catch {} } @@ -1749,7 +1785,7 @@ function findManifest(kbRoot, maxDepth = 3) { const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync3(full); } catch { continue; } @@ -1769,7 +1805,7 @@ function findManifest(kbRoot, maxDepth = 3) { const full = join5(root, name); let st; try { - st = statSync2(full); + st = statSync3(full); } catch { continue; } @@ -1859,7 +1895,7 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { return unconfiguredKbSnapshot(); let root = resolve(trimmed); try { - const st = statSync2(root); + const st = statSync3(root); if (st.isFile()) { if (basename(root).toLowerCase() === "manifest.json") { root = dirname3(root); @@ -1884,7 +1920,7 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { const manifestPath = located.paths[0]; let raw; try { - raw = readFileSync3(manifestPath, "utf8"); + raw = readFileSync4(manifestPath, "utf8"); } catch (error) { return errorSnapshot(`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`); } @@ -1926,7 +1962,7 @@ function renderKbPathTable(snapshot) { } // src/netx/kb-local-path.ts -import { existsSync as existsSync5, realpathSync, statSync as statSync3 } from "node:fs"; +import { existsSync as existsSync5, realpathSync, statSync as statSync4 } from "node:fs"; import { join as join6, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", @@ -2046,7 +2082,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync3(realFile).isFile()) { + if (!statSync4(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -2111,9 +2147,9 @@ function readKbIdentity(snapshot) { const full = join7(dir, name); let text; try { - if (!statSync4(full).isFile()) + if (!statSync5(full).isFile()) continue; - text = readFileSync4(full, "utf8").replace(/^\uFEFF/, "").trim(); + text = readFileSync5(full, "utf8").replace(/^\uFEFF/, "").trim(); } catch { continue; } @@ -2239,9 +2275,9 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync as readdirSync4, - readFileSync as readFileSync5, + readFileSync as readFileSync6, rmSync as rmSync2, - statSync as statSync5, + statSync as statSync6, writeFileSync as writeFileSync4 } from "node:fs"; import { dirname as dirname4, join as join8 } from "node:path"; @@ -2404,7 +2440,7 @@ function walkFiles(dir, baseLocal, out) { const full = join8(dir, name); let st; try { - st = statSync5(full); + st = statSync6(full); } catch { continue; } @@ -2438,7 +2474,7 @@ function listLocalFiles(snapshot, args = {}) { } // src/netx/kb-read-ops.ts -import { readdirSync as readdirSync5, readFileSync as readFileSync6, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs"; +import { readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, statSync as statSync7 } from "node:fs"; import { extname, join as join9, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); var SKIP_DIRS = new Set([".git", "node_modules"]); @@ -2483,14 +2519,14 @@ function resolveKbReadPath(snapshot, pathArg) { } function readKbFile(snapshot, args) { const target = resolveKbReadPath(snapshot, args.path); - const st = statSync6(target.absolutePath); + const st = statSync7(target.absolutePath); if (st.isDirectory()) { const entries = readdirSync5(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { if (entry.isDirectory()) return { name: entry.name, type: "dir" }; let size; try { - size = statSync6(join9(target.absolutePath, entry.name)).size; + size = statSync7(join9(target.absolutePath, entry.name)).size; } catch {} return { name: entry.name, type: "file", size }; }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); @@ -2499,7 +2535,7 @@ function readKbFile(snapshot, args) { if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { throw new Error(`not a text KB file: ${target.relativePath}`); } - const lines = readFileSync6(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` + const lines = readFileSync7(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` `).split(` `); const start = Math.max(1, Math.floor(args.offset ?? 1)); @@ -2544,7 +2580,7 @@ function cachedText(path, mtimeMs, size) { const hit = cache.files.get(path); if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return hit; - const text = readFileSync6(path, "utf8").replace(/^\uFEFF/, ""); + const text = readFileSync7(path, "utf8").replace(/^\uFEFF/, ""); if (hit) cache.chars -= hit.text.length; if (cache.chars + text.length > CACHE_MAX_CHARS) { @@ -2574,7 +2610,7 @@ function walkTextFiles(dir, out) { if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue; try { - const st = statSync6(full); + const st = statSync7(full); if (st.size <= SEARCH_MAX_FILE_BYTES) out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); } catch {} @@ -2604,7 +2640,7 @@ function searchKb(snapshot, args) { const files = []; const seen = new Set; for (const dir of dirs) { - const st = statSync6(dir); + const st = statSync7(dir); const batch = []; if (st.isFile()) batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); @@ -3273,7 +3309,7 @@ function replyReminder(setting) { // src/netx/session-export.ts import { homedir as homedir5, hostname as osHostname } from "node:os"; -// ../../netxops/node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs +// node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs import { createRequire as createRequire3 } from "module"; var require2 = createRequire3("/"); var _a; @@ -4540,7 +4576,7 @@ function mountNetxopsWebRoute(ctx, channel, handler) { } // src/netx/tools.ts -import { readFileSync as readFileSync7 } from "node:fs"; +import { readFileSync as readFileSync8 } from "node:fs"; import { dirname as dirname6, join as join12 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -5085,7 +5121,7 @@ async function refreshMcpCatalog(input) { function packageVersion() { try { const here = dirname6(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync7(join12(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync8(join12(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -5217,13 +5253,13 @@ function registerNetxTools(ctx, connection, options) { import { createScope, scopeOf } from "@deepseek-ai/dsh-scope"; // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync8 } from "node:fs"; +import { readFileSync as readFileSync9 } from "node:fs"; import { dirname as dirname7, join as join13 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; function packageVersion2() { try { const here = dirname7(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync8(join13(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync9(join13(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -5476,6 +5512,24 @@ function installPresetPublication(ctx) { } // src/index.ts +function mcpCatalogPayload(catalog) { + const cached = listCachedSkills(getNetxConnection()?.apiUrl ?? ""); + return { + phase: catalog.phase, + mcpUrl: catalog.mcpUrl, + error: catalog.error, + listedAt: catalog.listedAt, + fromCache: catalog.fromCache, + tools: catalog.tools.map((tool2) => ({ + name: tool2.name, + description: tool2.description, + readOnlyHint: tool2.readOnlyHint, + group: tool2.group ?? null + })), + skills: cached.skills, + skillsListedAt: cached.listedAt + }; +} var name = "netxops"; var inject = ["tools"]; var NETXOPS_RPC_CHANNEL = "/netxops"; @@ -5550,7 +5604,7 @@ function resolveApiUrlForConnection(value) { function packageVersion3() { try { const here = dirname8(fileURLToPath4(import.meta.url)); - const pkg = JSON.parse(readFileSync9(join14(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync10(join14(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -6147,23 +6201,7 @@ function apply(ctx, config = Config({})) { }; } if (endpoint === "tools.catalog") { - const catalog = getMcpCatalogState(); - return { - ok: true, - value: { - phase: catalog.phase, - mcpUrl: catalog.mcpUrl, - error: catalog.error, - listedAt: catalog.listedAt, - fromCache: catalog.fromCache, - tools: catalog.tools.map((tool2) => ({ - name: tool2.name, - description: tool2.description, - readOnlyHint: tool2.readOnlyHint, - group: tool2.group ?? null - })) - } - }; + return { ok: true, value: mcpCatalogPayload(getMcpCatalogState()) }; } if (endpoint === "tools.refresh") { const connection = getNetxConnection(); @@ -6177,14 +6215,7 @@ function apply(ctx, config = Config({})) { }); return { ok: catalog.phase === "connected", - value: { - phase: catalog.phase, - mcpUrl: catalog.mcpUrl, - error: catalog.error, - listedAt: catalog.listedAt, - fromCache: catalog.fromCache, - toolCount: catalog.tools.length - }, + value: { ...mcpCatalogPayload(catalog), toolCount: catalog.tools.length }, ...catalog.error ? { error: catalog.error } : {} }; } diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 1dddab0..40952b6 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -23,7 +23,7 @@ import { ensureStyles } from './styles.ts' import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts' import { parsePresetIds } from '../netx/capability-groups.ts' import { parseDisabledTools } from '../netx/mcp-catalog.ts' -import type { McpToolRow } from './mcp-catalog-view.ts' +import type { McpSkillRow, McpToolRow } from './mcp-catalog-view.ts' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' @@ -368,6 +368,10 @@ function CapabilityGroupBlock(props: { writeBadge?: string emptyLabel?: string onToggleTool?: (name: string, enabled: boolean) => void + skills?: McpSkillRow[] + skillsLabel?: string + skillsNoneLabel?: string + skillFilesLabel?: (count: number) => string }) { return (
@@ -396,6 +400,18 @@ function CapabilityGroupBlock(props: {
{props.targets} + {props.skills + ? ( +

+ {props.skillsLabel ?? 'Skill'}: + {props.skills.length === 0 + ? (props.skillsNoneLabel ?? '') + : props.skills + .map((skill) => `${skill.name}(${props.skillFilesLabel?.(skill.files) ?? skill.files})`) + .join('、')} +

+ ) + : null} {props.tools ? (
@@ -654,7 +670,12 @@ export function NetxopsCard(props: NetxopsCardProps) { bizMonitor: catalogTools.filter((tool) => tool.group === 'bizMonitor'), } const disabledSet = new Set(parseDisabledTools(state.disabledTools.text)) + const catalogSkills = state.mcpCatalog?.skills ?? [] + const skillsOf = (group: McpSkillRow['group']) => catalogSkills.filter((skill) => skill.group === group) const toolProps = { + skillsLabel: t('groupSkills'), + skillsNoneLabel: t('groupSkillsNone'), + skillFilesLabel: (count: number) => t('skillFiles').replace('{count}', String(count)), disabledTools: disabledSet, writeBadge: t('toolWriteBadge'), emptyLabel: t('toolListEmpty'), @@ -666,6 +687,7 @@ export function NetxopsCard(props: NetxopsCardProps) { title={t('groupOps')} toolCount={byGroup.ops.length} tools={byGroup.ops} + skills={skillsOf('ops')} targets={targets('groupOpsPresets', state.groupOpsPresets)} inPresetLabel={t('groupInPreset')} publicLabel={t('groupPublic')} @@ -684,6 +706,7 @@ export function NetxopsCard(props: NetxopsCardProps) { title={t('groupTopology')} toolCount={byGroup.topology.length} tools={byGroup.topology} + skills={skillsOf('topology')} targets={targets('groupTopologyPresets', state.groupTopologyPresets)} inPresetLabel={t('groupInPreset')} publicLabel={t('groupPublic')} @@ -702,6 +725,7 @@ export function NetxopsCard(props: NetxopsCardProps) { title={t('groupBizMonitor')} toolCount={byGroup.bizMonitor.length} tools={byGroup.bizMonitor} + skills={skillsOf('bizMonitor')} targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)} inPresetLabel={t('groupInPreset')} publicLabel={t('groupPublic')} diff --git a/src/client/locales.ts b/src/client/locales.ts index ce29a4c..9d87f0d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -72,6 +72,9 @@ export type NetxopsLocaleKey = | 'readOnlyOnly' | 'toolWriteBadge' | 'toolListEmpty' + | 'groupSkills' + | 'groupSkillsNone' + | 'skillFiles' | 'sessionsExportCount' | 'sessionsExportUnavailable' | 'sessionsExportNoPersistence' @@ -165,6 +168,9 @@ export const en: Record = { readOnlyOnly: 'Read-only tools only', toolWriteBadge: 'write', toolListEmpty: 'No tools yet', + groupSkills: 'Skills', + groupSkillsNone: 'None pulled from netx', + skillFiles: '{count} files', sessionsExportCount: '{count} sessions', sessionsExportUnavailable: 'Export unavailable', sessionsExportNoPersistence: 'Session store unavailable.', @@ -259,6 +265,9 @@ export const zh: Record = { readOnlyOnly: '只允许只读工具', toolWriteBadge: '写', toolListEmpty: '暂无工具清单', + groupSkills: 'Skill', + groupSkillsNone: '未从 netx 拉到', + skillFiles: '{count} 个文件', sessionsExportCount: '{count} 个会话', sessionsExportUnavailable: '无法导出', sessionsExportNoPersistence: '会话存储不可用。', diff --git a/src/client/mcp-catalog-view.ts b/src/client/mcp-catalog-view.ts index 8014d0d..4a6c9e1 100644 --- a/src/client/mcp-catalog-view.ts +++ b/src/client/mcp-catalog-view.ts @@ -15,6 +15,12 @@ export interface McpToolRow { group: 'ops' | 'topology' | 'bizMonitor' | null } +export interface McpSkillRow { + group: 'ops' | 'topology' | 'bizMonitor' + name: string + files: number +} + export interface McpCatalogView { phase: string mcpUrl: string @@ -22,6 +28,9 @@ export interface McpCatalogView { listedAt: string | null fromCache: boolean tools: McpToolRow[] + /** Skills in the local cache (pulled from netx `/mcp` resources). */ + skills: McpSkillRow[] + skillsListedAt: string | null } const EMPTY: McpCatalogView = { @@ -31,6 +40,8 @@ const EMPTY: McpCatalogView = { listedAt: null, fromCache: false, tools: [], + skills: [], + skillsListedAt: null, } export function asMcpCatalogView(value: unknown): McpCatalogView { @@ -54,6 +65,17 @@ export function asMcpCatalogView(value: unknown): McpCatalogView { }) } } + const skills: McpSkillRow[] = [] + if (Array.isArray(row.skills)) { + for (const raw of row.skills) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue + const s = raw as Record + const group = s.group === 'ops' || s.group === 'topology' || s.group === 'bizMonitor' ? s.group : null + const name = typeof s.name === 'string' ? s.name : '' + if (!group || !name) continue + skills.push({ group, name, files: typeof s.files === 'number' ? s.files : 0 }) + } + } return { phase: typeof row.phase === 'string' ? row.phase : 'idle', mcpUrl: typeof row.mcpUrl === 'string' ? row.mcpUrl : '', @@ -61,6 +83,8 @@ export function asMcpCatalogView(value: unknown): McpCatalogView { listedAt: typeof row.listedAt === 'string' ? row.listedAt : null, fromCache: row.fromCache === true, tools, + skills, + skillsListedAt: typeof row.skillsListedAt === 'string' ? row.skillsListedAt : null, } } diff --git a/src/index.ts b/src/index.ts index 9df7b9b..9e8cc34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,7 @@ import { } from './netx/model-language.ts' import { normalizeApiUrl, type NormalizeApiUrlResult } from './netx/http.ts' import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts' +import { listCachedSkills } from './netx/skills-cache.ts' import { getSessionsExportStatus, NETXOPS_SESSIONS_EXPORT_PATH, @@ -105,6 +106,26 @@ import { watchMcpCatalog, } from './netx/mcp-store.ts' +/** Plugins-card catalog payload: tools from the MCP store + skills from the disk cache. */ +function mcpCatalogPayload(catalog: ReturnType) { + const cached = listCachedSkills(getNetxConnection()?.apiUrl ?? '') + return { + phase: catalog.phase, + mcpUrl: catalog.mcpUrl, + error: catalog.error, + listedAt: catalog.listedAt, + fromCache: catalog.fromCache, + tools: catalog.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + readOnlyHint: tool.readOnlyHint, + group: tool.group ?? null, + })), + skills: cached.skills, + skillsListedAt: cached.listedAt, + } +} + // The event is absent from older peer declarations, where it is never emitted. declare module '@deepseek-ai/cordis' { interface Events { @@ -1064,23 +1085,7 @@ export function apply(ctx: Context, config: ConfigInput = Config({} as C } } if (endpoint === 'tools.catalog') { - const catalog = getMcpCatalogState() - return { - ok: true, - value: { - phase: catalog.phase, - mcpUrl: catalog.mcpUrl, - error: catalog.error, - listedAt: catalog.listedAt, - fromCache: catalog.fromCache, - tools: catalog.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - readOnlyHint: tool.readOnlyHint, - group: tool.group ?? null, - })), - }, - } + return { ok: true, value: mcpCatalogPayload(getMcpCatalogState()) } } if (endpoint === 'tools.refresh') { const connection = getNetxConnection() @@ -1093,14 +1098,7 @@ export function apply(ctx: Context, config: ConfigInput = Config({} as C }) return { ok: catalog.phase === 'connected', - value: { - phase: catalog.phase, - mcpUrl: catalog.mcpUrl, - error: catalog.error, - listedAt: catalog.listedAt, - fromCache: catalog.fromCache, - toolCount: catalog.tools.length, - }, + value: { ...mcpCatalogPayload(catalog), toolCount: catalog.tools.length }, ...(catalog.error ? { error: catalog.error } : {}), } } diff --git a/src/netx/skills-cache.ts b/src/netx/skills-cache.ts index 6cc04ea..9e606e0 100644 --- a/src/netx/skills-cache.ts +++ b/src/netx/skills-cache.ts @@ -9,12 +9,15 @@ import { existsSync, mkdirSync, readdirSync, + readFileSync, rmSync, + statSync, writeFileSync, } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { + CAPABILITY_GROUP_IDS, SKILL_DIR_BY_GROUP, type NetxCapabilityGroupId, } from './capability-groups.ts' @@ -105,3 +108,47 @@ export function hasSkillsCache(apiUrl: string): boolean { return false } } + +/** One cached skill as shown on the Plugins card. */ +export interface CachedSkillSummary { + group: NetxCapabilityGroupId + name: string + files: number +} + +function countFiles(dir: string): number { + let n = 0 + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + n += statSync(full).isDirectory() ? countFiles(full) : 1 + } + return n +} + +/** Skills currently in the cache for `apiUrl` (what the plugin will mount). */ +export function listCachedSkills(apiUrl: string): { listedAt: string | null; skills: CachedSkillSummary[] } { + const root = skillsCacheDir(apiUrl) + const skills: CachedSkillSummary[] = [] + let listedAt: string | null = null + if (!apiUrl.trim() || !existsSync(root)) return { listedAt, skills } + try { + const meta = JSON.parse(readFileSync(join(root, '.meta.json'), 'utf8')) as { listedAt?: unknown } + if (typeof meta.listedAt === 'string') listedAt = meta.listedAt + } catch { + // no meta yet + } + for (const group of CAPABILITY_GROUP_IDS) { + const groupDir = join(root, SKILL_DIR_BY_GROUP[group]) + if (!existsSync(groupDir)) continue + try { + for (const name of readdirSync(groupDir).sort()) { + const dir = join(groupDir, name) + if (!statSync(dir).isDirectory() || !existsSync(join(dir, 'SKILL.md'))) continue + skills.push({ group, name, files: countFiles(dir) }) + } + } catch { + // unreadable group dir → skip + } + } + return { listedAt, skills } +} diff --git a/test/group-skills.test.mjs b/test/group-skills.test.mjs index 3f38b55..7532b1c 100644 --- a/test/group-skills.test.mjs +++ b/test/group-skills.test.mjs @@ -14,6 +14,7 @@ import { refreshMcpCatalog, resetMcpCatalog } from '../src/netx/mcp-store.ts' import { publishNetxConnection } from '../src/netx/runtime.ts' import { hasSkillsCache, + listCachedSkills, replaceSkillsCache, skillsCacheDir, } from '../src/netx/skills-cache.ts' @@ -348,3 +349,19 @@ test('group off → skill not registered', async (t) => { await registerGroupSkills(ctx, [], 'test') assert.equal(registered.length, 0) }) + +test('listCachedSkills summarizes cached skills per group for the Plugins card', async (t) => { + withTempHome(t) + replaceSkillsCache(API, [ + { group: 'ops', skill: 'netx-ops', relPath: 'SKILL.md', text: OPS_SKILL }, + { group: 'ops', skill: 'netx-ops', relPath: 'reference.md', text: 'ref' }, + { group: 'bizMonitor', skill: 'netx-biz-monitor', relPath: 'SKILL.md', text: OPS_SKILL }, + ]) + const { listedAt, skills } = listCachedSkills(API) + assert.ok(listedAt) + assert.deepEqual(skills, [ + { group: 'ops', name: 'netx-ops', files: 2 }, + { group: 'bizMonitor', name: 'netx-biz-monitor', files: 1 }, + ]) + assert.deepEqual(listCachedSkills('').skills, []) +})