feat(card): show cached netx skills per capability group

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 23:23:40 +08:00
parent fa82f530e9
commit 056f2d4ea3
9 changed files with 285 additions and 96 deletions

View file

@ -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"

View file

@ -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: "会话存储不可用。",

View file

@ -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 } : {}
};
}

View file

@ -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 (
<div className="dsh-nx-groupBlock" role="group" aria-label={props.title}>
@ -396,6 +400,18 @@ function CapabilityGroupBlock(props: {
</label>
</div>
{props.targets}
{props.skills
? (
<p className="dsh-nx-hint">
{props.skillsLabel ?? 'Skill'}:
{props.skills.length === 0
? (props.skillsNoneLabel ?? '')
: props.skills
.map((skill) => `${skill.name}(${props.skillFilesLabel?.(skill.files) ?? skill.files})`)
.join('、')}
</p>
)
: null}
{props.tools
? (
<details className="dsh-nx-toolList">
@ -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')}

View file

@ -72,6 +72,9 @@ export type NetxopsLocaleKey =
| 'readOnlyOnly'
| 'toolWriteBadge'
| 'toolListEmpty'
| 'groupSkills'
| 'groupSkillsNone'
| 'skillFiles'
| 'sessionsExportCount'
| 'sessionsExportUnavailable'
| 'sessionsExportNoPersistence'
@ -165,6 +168,9 @@ export const en: Record<NetxopsLocaleKey, string> = {
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<NetxopsLocaleKey, string> = {
readOnlyOnly: '只允许只读工具',
toolWriteBadge: '写',
toolListEmpty: '暂无工具清单',
groupSkills: 'Skill',
groupSkillsNone: '未从 netx 拉到',
skillFiles: '{count} 个文件',
sessionsExportCount: '{count} 个会话',
sessionsExportUnavailable: '无法导出',
sessionsExportNoPersistence: '会话存储不可用。',

View file

@ -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<string, unknown>
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,
}
}

View file

@ -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<typeof getMcpCatalogState>) {
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> = 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> = 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 } : {}),
}
}

View file

@ -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 }
}

View file

@ -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, [])
})