dsh-armory 2.0.0 → 2.2.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/README.en.md +16 -6
- package/README.md +21 -7
- package/lib/client.js +1656 -111
- package/lib/index.js +54 -10
- package/lib/mcp-oauth.js +1 -1
- package/lib/skill-remove.js +125 -0
- package/lib/suites-registry.js +179 -0
- package/lib/tool-ledger.js +180 -11
- package/lib/typert.js +11 -0
- package/lib/wb-awesome-market.js +305 -37
- package/lib/wb-install-state.js +1 -1
- package/lib/wb-market.js +44 -21
- package/lib/wb-registry.js +1 -1
- package/lib/whats-new.js +46 -0
- package/lib/workspace-runtime.js +1 -1
- package/package.json +2 -2
- package/docs/chunk-split-feasibility.md +0 -153
- package/docs/host-proposal-mcp-teardown.md +0 -184
- package/docs/host-proposal-skill-toggle.md +0 -166
- package/docs/prd-awesome-dsh-plugins.md +0 -105
- package/docs/prd-workbuddy-parity.md +0 -128
- package/docs/system-design-awesome.md +0 -269
- package/docs/system_design.md +0 -530
- package/docs/workbuddy-market-gap-analysis.md +0 -197
package/lib/index.js
CHANGED
|
@@ -12,14 +12,18 @@ import { admitImportedServers } from "./import-admission.js";
|
|
|
12
12
|
import { collectImportLocations, missingEnvNames, resolveImportLocation } from "./mcp-import-sources.js";
|
|
13
13
|
import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observability.js";
|
|
14
14
|
import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
|
|
15
|
+
// Skill 看板的卸载落盘点:来源白名单 + 根目录包含性 + 符号链接/根目录校验(lib/skill-remove.js)。
|
|
16
|
+
import { removeSkillFromDisk } from "./skill-remove.js";
|
|
15
17
|
import { WHATS_NEW } from "./whats-new.js";
|
|
16
18
|
import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCount, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
|
|
17
19
|
// T03:多市场注册表(R4)与 codebuddy 插件市场(R5)的宿主侧数据层。
|
|
18
20
|
import { readRegistry, upsertMarketplace } from "./wb-registry.js";
|
|
19
21
|
import { wbPluginCatalog, wbPluginDetail, wbPluginUpdateCheck } from "./wb-plugin-market.js";
|
|
20
22
|
// T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)的宿主侧数据层。
|
|
21
|
-
import { wbAwesomeCatalog, wbAwesomeDetail, wbAwesomeUpdateCheck } from "./wb-awesome-market.js";
|
|
23
|
+
import { wbAwesomeCatalog, wbAwesomeDetail, wbAwesomeReadme, wbAwesomeUpdateCheck } from "./wb-awesome-market.js";
|
|
22
24
|
import { readState as readInstallState, record as recordInstallState, forget as forgetInstallState } from "./wb-install-state.js";
|
|
25
|
+
// 自定义套件(Skill 看板):读 / 整集合覆盖保存,数据层 lib/suites-registry.js。
|
|
26
|
+
import { readSuites as readSkillSuites, saveSuites as writeSkillSuites } from "./suites-registry.js";
|
|
23
27
|
import { applyTokens } from "./wb-auth-rules.js";
|
|
24
28
|
import {
|
|
25
29
|
authorizationOverview as oauthOverview,
|
|
@@ -33,8 +37,8 @@ import {
|
|
|
33
37
|
} from "./mcp-oauth.js";
|
|
34
38
|
import { hasTeamBridge, installExpertSkills, listExpertRecent, listExpertSkills, prepareExpertPersona, readExpertPresets, recordExpertRecent, removeExpertPreset, uninstallExpertSkills, uninstallTeamMemberPersonas, wbExpertCatalog, wbExpertDetail, writeExpertPreset } from "./expert-market.js";
|
|
35
39
|
import { attachPersona, detachPersona, listPersonas, reconcilePersonas } from "./session-persona.js";
|
|
36
|
-
//
|
|
37
|
-
import {
|
|
40
|
+
// clearTextCache 的 import 已随 toolTailor 的「清空缓存」动作一起删掉(见下方 toolTailor 注释)。
|
|
41
|
+
import { scanSessionLedger } from "./tool-ledger.js";
|
|
38
42
|
import { evaluateEnvExpression, jsExpressionToTemplate } from "./workspace-config.js";
|
|
39
43
|
import { canonicalWorkspacePath, installAgentRuntime, listWorkspaceRecords, liveWorkspaceAgentCount, projectConnectionsView, readWorkspaceConfigCached, reconcileWorkspaceConnections, reconcileWorkspaceRestricts, reconnectWorkspaceServer, withWorkspaceWrite, workspaceConnectionStatus, workspaceMountErrorsView, workspaceRestrictErrorView, workspaceScopeErrorsView, workspaceToolSchemas, writeWorkspaceConfigFile } from "./workspace-runtime.js";
|
|
40
44
|
|
|
@@ -989,7 +993,7 @@ async function listMcpServers(ctx) {
|
|
|
989
993
|
let McpManagerGateway = (() => {
|
|
990
994
|
let _classSuper = TypertRemoteService;
|
|
991
995
|
let _instanceExtraInitializers = [];
|
|
992
|
-
let _list_decorators, _status_decorators, _enable_decorators, _disable_decorators, _reconnect_decorators, _tools_decorators, _builtins_decorators, _installBuiltins_decorators, _add_decorators, _update_decorators, _remove_decorators, _reveal_decorators, _previewImport_decorators, _importJson_decorators, _listWorkspaces_decorators, _getWorkspaceView_decorators, _addWorkspaceServer_decorators, _updateWorkspaceServer_decorators, _removeWorkspaceServer_decorators, _setWorkspaceExclude_decorators, _installWorkspaceBuiltins_decorators, _previewWorkspaceImport_decorators, _importWorkspaceJson_decorators, _revealWorkspaceServer_decorators, _projectConnections_decorators, _reconnectWorkspaceServer_decorators, _updateHint_decorators, _whatsNew_decorators, _scanImportSources_decorators, _previewImportSource_decorators, _importSource_decorators, _toolTailor_decorators, _wbConnectorCatalog_decorators, _wbConnectorCachedCount_decorators, _wbConnectorPreview_decorators, _wbConnectorInstall_decorators, _wbOAuthBegin_decorators, _wbOAuthPoll_decorators, _wbOAuthCancel_decorators, _wbOAuthToken_decorators, _wbOAuthOverview_decorators, _wbOAuthOpen_decorators, _wbSkillCatalog_decorators, _wbSkillDetail_decorators, _wbSkillInstall_decorators, _wbSkillUninstall_decorators, _wbSkillInstalled_decorators, _wbMarketRegistry_decorators, _wbMarketRegistryUpsert_decorators, _wbPluginCatalog_decorators, _wbPluginDetail_decorators, _wbInstallState_decorators, _wbInstallStateRecord_decorators, _wbInstallStateForget_decorators, _wbPluginUpdateCheck_decorators, _wbAwesomeCatalog_decorators, _wbAwesomeDetail_decorators, _wbAwesomeUpdateCheck_decorators;
|
|
996
|
+
let _list_decorators, _status_decorators, _enable_decorators, _disable_decorators, _reconnect_decorators, _tools_decorators, _builtins_decorators, _installBuiltins_decorators, _add_decorators, _update_decorators, _remove_decorators, _reveal_decorators, _previewImport_decorators, _importJson_decorators, _listWorkspaces_decorators, _getWorkspaceView_decorators, _addWorkspaceServer_decorators, _updateWorkspaceServer_decorators, _removeWorkspaceServer_decorators, _setWorkspaceExclude_decorators, _installWorkspaceBuiltins_decorators, _previewWorkspaceImport_decorators, _importWorkspaceJson_decorators, _revealWorkspaceServer_decorators, _projectConnections_decorators, _reconnectWorkspaceServer_decorators, _updateHint_decorators, _whatsNew_decorators, _scanImportSources_decorators, _previewImportSource_decorators, _importSource_decorators, _toolTailor_decorators, _wbConnectorCatalog_decorators, _wbConnectorCachedCount_decorators, _wbConnectorPreview_decorators, _wbConnectorInstall_decorators, _wbOAuthBegin_decorators, _wbOAuthPoll_decorators, _wbOAuthCancel_decorators, _wbOAuthToken_decorators, _wbOAuthOverview_decorators, _wbOAuthOpen_decorators, _wbSkillCatalog_decorators, _wbSkillDetail_decorators, _wbSkillInstall_decorators, _wbSkillUninstall_decorators, _wbSkillInstalled_decorators, _wbMarketRegistry_decorators, _wbMarketRegistryUpsert_decorators, _wbPluginCatalog_decorators, _wbPluginDetail_decorators, _wbInstallState_decorators, _wbInstallStateRecord_decorators, _wbInstallStateForget_decorators, _wbPluginUpdateCheck_decorators, _wbAwesomeCatalog_decorators, _wbAwesomeDetail_decorators, _wbAwesomeReadme_decorators, _wbAwesomeUpdateCheck_decorators;
|
|
993
997
|
return class McpManagerGateway extends _classSuper {
|
|
994
998
|
static {
|
|
995
999
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
@@ -1111,6 +1115,8 @@ let McpManagerGateway = (() => {
|
|
|
1111
1115
|
__esDecorate(this, null, _wbAwesomeCatalog_decorators, { kind: "method", name: "wbAwesomeCatalog", static: false, private: false, access: { has: (obj) => "wbAwesomeCatalog" in obj, get: (obj) => obj.wbAwesomeCatalog }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1112
1116
|
_wbAwesomeDetail_decorators = [Remote("wbAwesomeDetail")];
|
|
1113
1117
|
__esDecorate(this, null, _wbAwesomeDetail_decorators, { kind: "method", name: "wbAwesomeDetail", static: false, private: false, access: { has: (obj) => "wbAwesomeDetail" in obj, get: (obj) => obj.wbAwesomeDetail }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1118
|
+
_wbAwesomeReadme_decorators = [Remote("wbAwesomeReadme")];
|
|
1119
|
+
__esDecorate(this, null, _wbAwesomeReadme_decorators, { kind: "method", name: "wbAwesomeReadme", static: false, private: false, access: { has: (obj) => "wbAwesomeReadme" in obj, get: (obj) => obj.wbAwesomeReadme }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1114
1120
|
_wbAwesomeUpdateCheck_decorators = [Remote("wbAwesomeUpdateCheck")];
|
|
1115
1121
|
__esDecorate(this, null, _wbAwesomeUpdateCheck_decorators, { kind: "method", name: "wbAwesomeUpdateCheck", static: false, private: false, access: { has: (obj) => "wbAwesomeUpdateCheck" in obj, get: (obj) => obj.wbAwesomeUpdateCheck }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1116
1122
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
@@ -1308,12 +1314,12 @@ let McpManagerGateway = (() => {
|
|
|
1308
1314
|
async toolTailor(payload) {
|
|
1309
1315
|
// 只读账本:扫描本机会话日志 + 当前注册表 Schema,见 tool-ledger.js。
|
|
1310
1316
|
// days 由调用方传(7/30/90),缺省 30,服务端钳到 [1,365]。
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
+
// 「清空解压文本缓存」这个可选动作已删(2026-10-23):它曾声称「先清后扫,面板立即
|
|
1318
|
+
// 看到占用归零」,实测恰好相反 —— 快照取在扫描之后,同一发调用里日志被重新解压回填,
|
|
1319
|
+
// 前后都是 32 条 / 65.8 MB;而 clearTextCache() 的返回值(clearedBytes)压根没进报告
|
|
1320
|
+
// (textCacheStats() 没这个键),面板只能恒显示「正在清空缓存…」。既然既不回收也不修
|
|
1321
|
+
// 任何事,就不再留这条 RPC 分支。tool-ledger.js 仍导出 clearTextCache 原语:它是将来
|
|
1322
|
+
// 真要改缓存策略(槽位按文件数自适应、或只缓存最近若干会话)时的落点,只是现在无人调用。
|
|
1317
1323
|
return buildToolTailorReport(this.ctx, payload || {});
|
|
1318
1324
|
}
|
|
1319
1325
|
async listWorkspaces() {
|
|
@@ -1616,6 +1622,41 @@ let McpManagerGateway = (() => {
|
|
|
1616
1622
|
else spawn(process.platform === "darwin" ? "open" : "xdg-open", [path], { detached: true, stdio: "ignore" }).unref();
|
|
1617
1623
|
return { note: "已交给系统打开" };
|
|
1618
1624
|
}
|
|
1625
|
+
// 卸载一条已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。
|
|
1626
|
+
// 只信注册表给的 source/path(客户端传的路径不参与判断);bundled / runtime / custom
|
|
1627
|
+
// 由数据层拒绝并给出原因。删除后宿主注册表可能缓存了目录扫描结果——调用方
|
|
1628
|
+
// (SkillBoard)先本地收敛列表再 listSkills 刷新,不把「没立刻消失」当成失败。
|
|
1629
|
+
async removeSkill(payload) {
|
|
1630
|
+
const name = String(payload?.name || "").trim();
|
|
1631
|
+
if (!name) throw new Error("缺少 skill 名称");
|
|
1632
|
+
const result = await withSkillScope(this.ctx, payload, async (registry, options) => {
|
|
1633
|
+
if (typeof registry.get !== "function") throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法卸载");
|
|
1634
|
+
const skill = await registry.get(name, options);
|
|
1635
|
+
if (!skill) throw new Error("没有找到这个 skill(可能刚被移动或删除):" + name);
|
|
1636
|
+
return removeSkillFromDisk(skill, { cwd: options.cwd, home: homedir() });
|
|
1637
|
+
});
|
|
1638
|
+
if (result === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法卸载");
|
|
1639
|
+
return { note: `已卸载 ${name}(${result.kind === "symlink" ? "符号链接" : "目录"}:${result.path})` };
|
|
1640
|
+
}
|
|
1641
|
+
// ── 自定义套件(Skill 看板)─────────────────────────────────────────
|
|
1642
|
+
// 数据层在 lib/suites-registry.js(~/.dsh/armory/suites.json);这里只做 RPC 转发。
|
|
1643
|
+
// listSkillSuites —— 永不抛错:文件缺失 / JSON 损坏 / 结构不对都回落空集合,
|
|
1644
|
+
// 坏文件不该让整个 Skill 看板打不开。
|
|
1645
|
+
// saveSkillSuites —— 整集合覆盖(UI 的「保存」一次提交右侧全部套件)。
|
|
1646
|
+
// payload 形状不合格一律**抛错**,绝不回落成空数组:那等于把用户攒的套件全删了。
|
|
1647
|
+
// 脏数据(无名 / 空成员 / id 重复 / 超上限)由数据层拒绝且不写盘,错误原样冒到面板。
|
|
1648
|
+
// 不加 Remote 装饰器:与 listSkills / expertAttach 同一形态——strict 声明即可达,
|
|
1649
|
+
// 装饰器只是 SRC fallback 的 marker(方向说明见 test/contract-consistency.mjs 顶部)。
|
|
1650
|
+
async listSkillSuites() {
|
|
1651
|
+
return readSkillSuites();
|
|
1652
|
+
}
|
|
1653
|
+
async saveSkillSuites(payload) {
|
|
1654
|
+
const suites = Array.isArray(payload)
|
|
1655
|
+
? payload
|
|
1656
|
+
: (payload && typeof payload === "object" && Array.isArray(payload.suites) ? payload.suites : null);
|
|
1657
|
+
if (suites === null) throw new Error("缺少套件集合:payload.suites 必须是数组");
|
|
1658
|
+
return writeSkillSuites(suites);
|
|
1659
|
+
}
|
|
1619
1660
|
// ── WorkBuddy 市场直连(连接器 MCP + SkillHub 技能)─────────────────
|
|
1620
1661
|
// 数据与安装逻辑都在 lib/wb-market.js;这里只做 RPC 转发与作用域相关的写盘收尾。
|
|
1621
1662
|
// 连接器安装复用 applyManagedImport / applyWorkspaceImport:准入、同名跳过、entry id
|
|
@@ -1840,6 +1881,9 @@ let McpManagerGateway = (() => {
|
|
|
1840
1881
|
async wbAwesomeDetail(payload) {
|
|
1841
1882
|
return wbAwesomeDetail(payload?.name);
|
|
1842
1883
|
}
|
|
1884
|
+
async wbAwesomeReadme(payload) {
|
|
1885
|
+
return wbAwesomeReadme(payload || {});
|
|
1886
|
+
}
|
|
1843
1887
|
async wbAwesomeUpdateCheck() {
|
|
1844
1888
|
return wbAwesomeUpdateCheck();
|
|
1845
1889
|
}
|
package/lib/mcp-oauth.js
CHANGED
|
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
|
|
31
31
|
/** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
|
|
32
|
-
const PLUGIN_VERSION = "2.
|
|
32
|
+
const PLUGIN_VERSION = "2.2.0";
|
|
33
33
|
const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
|
|
34
34
|
const NET_TIMEOUT_MS = 15_000;
|
|
35
35
|
/** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// 已安装 skill 的卸载数据层(Skill 看板「卸载」的唯一落盘点)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不让客户端传路径直接删:path 来自 listSkills 的回包,但 RPC 入参是浏览器可写的。
|
|
4
|
+
// 这里只认「宿主注册表给这条 skill 的真实 source + path」,并且把删除范围钉死在四类
|
|
5
|
+
// skills 根目录里——用户级 ~/.agents/skills、~/.dsh/skills,项目级 <cwd>/.agents/skills、
|
|
6
|
+
// <cwd>/.dsh/skills。随 DSH 分发(bundled)、运行时注册(runtime)、自定义目录(custom)
|
|
7
|
+
// 一律拒绝:前两者不该由用户面板删,后者没有可信的根目录可用来做包含性校验。
|
|
8
|
+
//
|
|
9
|
+
// 安全闸门(按顺序,任一不过即抛错,绝不"尽力而为"地删):
|
|
10
|
+
// 1. source 在白名单内,且能推导出绝对根目录(项目级必须有 cwd);
|
|
11
|
+
// 2. path 是绝对路径,且 resolve 后严格位于根目录内(≠ 根自身,`..` 穿越不过);
|
|
12
|
+
// 宿主注册表返回的 path 通常指向 `<skill>/SKILL.md`,先归一化到其父目录再校验,
|
|
13
|
+
// 否则会把 SKILL.md 当成「不是目录」误拒(GUI 验证抓到的真实形态);
|
|
14
|
+
// 3. 父目录的 realpath 必须仍在根目录 realpath 内(防祖先符号链接把写入面带到别处);
|
|
15
|
+
// 4. 目标是符号链接 → 只 unlink 链接本身,**绝不**触碰链接指向的目录;
|
|
16
|
+
// 5. 目标是目录 → 其 realpath 仍在根内,且目录里确实有 SKILL.md(文件),才递归删除。
|
|
17
|
+
//
|
|
18
|
+
// 删除是**永久删除**(不进回收站):UI 侧有二次确认,并明示不可撤销。这与用户对删除的
|
|
19
|
+
// 偏好一致;不做「移到 .trash」这类软删除——那会让看板显示已卸载、磁盘却仍在。
|
|
20
|
+
//
|
|
21
|
+
// 纯数据层:零网络、零宿主依赖;home / cwd 可注入,测试用 mkdtemp 隔离。
|
|
22
|
+
import { lstat, realpath, rm, stat, unlink } from "node:fs/promises";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** 可卸载来源:值与宿主注册表的 skill.source 一致,客户端 SKILL_REMOVABLE_SOURCES 必须同步。 */
|
|
27
|
+
export const REMOVABLE_SKILL_SOURCES = ["user-agents", "user-dsh", "project-agents", "project-dsh"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 来源 → skills 根目录。project-* 需要 cwd;拿不到时返回 null(拒绝,而不是猜一个目录)。
|
|
31
|
+
* 只写「从哪来」,不校验存在性——存在性在删除流程里按目标路径判断。
|
|
32
|
+
*/
|
|
33
|
+
export function skillRootFor(source, options = {}) {
|
|
34
|
+
const home = options.home || homedir();
|
|
35
|
+
const cwd = options.cwd;
|
|
36
|
+
switch (source) {
|
|
37
|
+
case "user-agents": return join(home, ".agents", "skills");
|
|
38
|
+
case "user-dsh": return join(home, ".dsh", "skills");
|
|
39
|
+
case "project-agents": return cwd ? join(cwd, ".agents", "skills") : null;
|
|
40
|
+
case "project-dsh": return cwd ? join(cwd, ".dsh", "skills") : null;
|
|
41
|
+
default: return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 严格包含:child 在 parent 之内且不等于 parent。 */
|
|
46
|
+
function isInside(parent, child) {
|
|
47
|
+
const rel = relative(parent, child);
|
|
48
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 包含或相等:符号链接项只做父目录校验,父目录可以就是根。 */
|
|
52
|
+
function isInsideOrEqual(parent, child) {
|
|
53
|
+
const rel = relative(parent, child);
|
|
54
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function describeSource(source) {
|
|
58
|
+
return source ? `「${source}」` : "未知来源";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 从磁盘永久删除一条已发现的 skill。
|
|
63
|
+
*
|
|
64
|
+
* @param {{ name?: string, source?: string, path?: string }} skill 宿主注册表返回的条目(path 可为目录或目录内的 SKILL.md)
|
|
65
|
+
* @param {{ cwd?: string, home?: string }} [options]
|
|
66
|
+
* @returns {Promise<{ path: string, kind: "symlink" | "dir" }>}
|
|
67
|
+
*/
|
|
68
|
+
export async function removeSkillFromDisk(skill, options = {}) {
|
|
69
|
+
const source = typeof skill?.source === "string" ? skill.source : "";
|
|
70
|
+
const root = skillRootFor(source, options);
|
|
71
|
+
if (!root) {
|
|
72
|
+
if (source === "project-agents" || source === "project-dsh") {
|
|
73
|
+
throw new Error(`卸载项目级 skill 需要当前项目路径(来源:${describeSource(source)})`);
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`这个 skill 来自${describeSource(source)},不在可卸载范围内(只支持用户级/项目级的 .dsh/skills 与 .agents/skills)`);
|
|
76
|
+
}
|
|
77
|
+
if (!isAbsolute(root)) throw new Error("拒绝卸载:无法确定 skills 根目录的绝对路径");
|
|
78
|
+
const raw = typeof skill?.path === "string" ? skill.path.trim() : "";
|
|
79
|
+
if (!raw) throw new Error("这个 skill 没有可卸载的磁盘路径(运行时注册的 skill 不落盘)");
|
|
80
|
+
if (!isAbsolute(raw)) throw new Error("拒绝卸载:skill 路径不是绝对路径");
|
|
81
|
+
|
|
82
|
+
const rawAbs = resolve(raw);
|
|
83
|
+
// 注册表把 skill.path 指向清单文件(<skill>/SKILL.md),删除目标是它所在的目录。
|
|
84
|
+
// 若直接拿清单路径去删,只会得到「该路径不是目录」——真实 GUI 首跑就撞上了。
|
|
85
|
+
const target = basename(rawAbs).toLowerCase() === "skill.md" ? dirname(rawAbs) : rawAbs;
|
|
86
|
+
const rootAbs = resolve(root);
|
|
87
|
+
if (!isInside(rootAbs, target)) {
|
|
88
|
+
throw new Error(`拒绝卸载:skill 路径不在预期的 skills 根目录内(${rootAbs})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let info;
|
|
92
|
+
try {
|
|
93
|
+
info = await lstat(target);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code === "ENOENT") throw new Error("这个 skill 已经不在磁盘上了(可能刚被移动或删除)");
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 父目录的 realpath 校验:防「根内路径 → 祖先符号链接实际指向根外」。根目录本身是
|
|
100
|
+
// 符号链接(例如 ~/.agents → 别处)是允许的,所以比的是 realpath 后的根。
|
|
101
|
+
const rootReal = await realpath(rootAbs);
|
|
102
|
+
const parentReal = await realpath(dirname(target));
|
|
103
|
+
if (!isInsideOrEqual(rootReal, parentReal)) {
|
|
104
|
+
throw new Error("拒绝卸载:skill 路径经符号链接指向了 skills 根目录之外");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (info.isSymbolicLink()) {
|
|
108
|
+
// 只删链接本身。链接可能指向任意位置(例如 tabbit 这种外部安装的 skill),
|
|
109
|
+
// rm/lstat 都不会跟随,但我们仍显式只用 unlink,语义一目了然。
|
|
110
|
+
await unlink(target);
|
|
111
|
+
return { path: target, kind: "symlink" };
|
|
112
|
+
}
|
|
113
|
+
if (!info.isDirectory()) throw new Error("拒绝卸载:该路径不是目录(也不是符号链接)");
|
|
114
|
+
|
|
115
|
+
const targetReal = await realpath(target);
|
|
116
|
+
if (!isInside(rootReal, targetReal)) {
|
|
117
|
+
throw new Error("拒绝卸载:skill 目录经符号链接指向了 skills 根目录之外");
|
|
118
|
+
}
|
|
119
|
+
const manifest = await stat(join(target, "SKILL.md")).catch(() => null);
|
|
120
|
+
if (!manifest || !manifest.isFile()) {
|
|
121
|
+
throw new Error("拒绝卸载:目录里没有 SKILL.md,看起来不是 skill 目录");
|
|
122
|
+
}
|
|
123
|
+
await rm(target, { recursive: true, force: false });
|
|
124
|
+
return { path: target, kind: "dir" };
|
|
125
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// 自定义套件(Skill 看板)—— ~/.dsh/armory/suites.json 的读写层。
|
|
2
|
+
//
|
|
3
|
+
// 职责:把「一组相关 skill 的命名组合」持久化,供两处消费——
|
|
4
|
+
// 1. Skill 看板工具栏的「自定义套件」按钮 → 套件管理弹窗(增删改);
|
|
5
|
+
// 2. 输入框召唤菜单的技能子菜单 → 选中一个套件即把成员的 `/name` 逐个插进草稿,
|
|
6
|
+
// 一次引用等于同时应用多个相关 skill(纯插件侧展开,宿主零改动)。
|
|
7
|
+
//
|
|
8
|
+
// 契约(与 UI 的分工,勿两头各写一套):
|
|
9
|
+
// - id 由**本层**分配:UI 新建的套件传 id: '',保存后必须以返回值里的 id 为准,
|
|
10
|
+
// 不得拿旧副本再存一次(否则同一套件会分裂成两条)。
|
|
11
|
+
// - members 是**有序**数组(拖拽排序即数组序),展开时按序拼 `/name`。
|
|
12
|
+
// - 保存语义是**整集合覆盖**(UI 的「保存」一次提交右侧全部套件),不是增量 upsert;
|
|
13
|
+
// 原子性靠 staging + rename,所以不存在「写一半」的中间态。
|
|
14
|
+
//
|
|
15
|
+
// 失败模式分档(对齐 execution-safety「失败模式处置分档」):
|
|
16
|
+
// - 拒绝(抛错):入参不是数组 / 条目不是对象 / 名称为空 / 成员为空 /
|
|
17
|
+
// 非空 id 重复 / 套件总数超上限。这些都是「写进去就是脏数据」的形态。
|
|
18
|
+
// - 放行 + 截断:name / desc 超长、members 超上限、members 里的非字符串项——
|
|
19
|
+
// 截断与丢弃都发生在 normalize 里,返回值就是落盘值,UI 以返回值为准。
|
|
20
|
+
// - 读取永不抛错:文件缺失 / JSON 损坏 / 结构不对 → 空集合(与 wb-registry、
|
|
21
|
+
// wb-install-state 同一口径),坏文件不炸面板。
|
|
22
|
+
//
|
|
23
|
+
// 纯数据层:零网络、零宿主依赖。存储根目录可注入(options.rootDir / 环境变量
|
|
24
|
+
// DSH_ARMORY_HOME),测试用 mkdtemp 隔离,绝不碰真实 ~/.dsh。
|
|
25
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const SUITES_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** 套件名上限:召唤菜单一行 28px,名字过长会挤掉成员计数徽章。 */
|
|
32
|
+
export const SUITE_NAME_MAX = 40;
|
|
33
|
+
/** 说明上限:只用于弹窗与菜单 title 悬停,不做正文展示。 */
|
|
34
|
+
export const SUITE_DESC_MAX = 120;
|
|
35
|
+
/** 单套件成员上限:展开后是一整串 `/name`,再多草稿框也读不下去。 */
|
|
36
|
+
export const SUITE_MEMBERS_MAX = 40;
|
|
37
|
+
/** 套件总数上限:文件体积与菜单长度的双重保险丝。 */
|
|
38
|
+
export const SUITES_MAX = 100;
|
|
39
|
+
|
|
40
|
+
/** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry 同一解析序)。 */
|
|
41
|
+
function armoryRoot(options = {}) {
|
|
42
|
+
return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 套件文件绝对路径(入参只能选根目录,文件名不可外部指定——避免路径穿越面)。 */
|
|
46
|
+
export function suitesPath(options = {}) {
|
|
47
|
+
return join(armoryRoot(options), "suites.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asString(value, fallback = "") {
|
|
51
|
+
return typeof value === "string" ? value : fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asTimestamp(value, fallback = 0) {
|
|
55
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 截断到上限(超长属「放行 + 截断」档,不抛错)。 */
|
|
59
|
+
function clampText(value, max) {
|
|
60
|
+
const text = asString(value).trim();
|
|
61
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 成员数组归一:只留非空字符串、按首次出现去重、截到上限(顺序即展开序)。 */
|
|
65
|
+
function normalizeMembers(raw) {
|
|
66
|
+
if (!Array.isArray(raw)) return [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const members = [];
|
|
69
|
+
for (const item of raw) {
|
|
70
|
+
const name = typeof item === "string" ? item.trim() : "";
|
|
71
|
+
if (!name || seen.has(name)) continue;
|
|
72
|
+
seen.add(name);
|
|
73
|
+
members.push(name);
|
|
74
|
+
if (members.length >= SUITE_MEMBERS_MAX) break;
|
|
75
|
+
}
|
|
76
|
+
return members;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把任意原始对象规范成一条完整的 Suite:缺失字段防御性补全,畸形成员丢弃。
|
|
81
|
+
* id 为空表示「新建」,由 saveSuites 分配;normalizeSuite 本身不造 id(保持纯函数)。
|
|
82
|
+
*/
|
|
83
|
+
export function normalizeSuite(raw) {
|
|
84
|
+
return {
|
|
85
|
+
id: asString(raw?.id).trim(),
|
|
86
|
+
name: clampText(raw?.name, SUITE_NAME_MAX),
|
|
87
|
+
desc: clampText(raw?.desc, SUITE_DESC_MAX),
|
|
88
|
+
members: normalizeMembers(raw?.members),
|
|
89
|
+
createdAt: asTimestamp(raw?.createdAt, 0),
|
|
90
|
+
updatedAt: asTimestamp(raw?.updatedAt, 0),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 默认 id 生成器:时间戳 36 进制 + 随机尾,够避免同毫秒连点撞车。 */
|
|
95
|
+
function defaultCreateId() {
|
|
96
|
+
return `suite-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 从磁盘读原始文档;不存在/损坏一律返回 null(由调用方决定回落语义)。 */
|
|
100
|
+
async function readRawDoc(path) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
103
|
+
} catch {
|
|
104
|
+
return null; // ENOENT / ENOTDIR / JSON 解析失败同途处理
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 拆出目录部分(路径既有 / 也有 \ 的 Windows 形态,不为 dirname 多引依赖)。 */
|
|
109
|
+
function dirnameOf(path) {
|
|
110
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
111
|
+
return cut > 0 ? path.slice(0, cut) : path;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式;Windows rename 不覆盖)。 */
|
|
115
|
+
async function writeAtomic(path, doc) {
|
|
116
|
+
await mkdir(dirnameOf(path), { recursive: true });
|
|
117
|
+
await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
|
|
118
|
+
await rm(path, { force: true });
|
|
119
|
+
await rename(`${path}.staging`, path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 读取时同样过一遍 normalize:手改坏的条目在读侧就被拉回合法形状。 */
|
|
123
|
+
function normalizeList(raw) {
|
|
124
|
+
if (!Array.isArray(raw)) return [];
|
|
125
|
+
return raw
|
|
126
|
+
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
|
|
127
|
+
.map(normalizeSuite)
|
|
128
|
+
.filter((suite) => suite.id && suite.name && suite.members.length);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 读全部套件。文件缺失 / 损坏 / 结构不对 → { version, suites: [] },永不抛错。
|
|
133
|
+
* 读侧过滤掉「无 id / 无名 / 无成员」的残缺条目:它们展开后什么也插不进草稿,
|
|
134
|
+
* 留在列表里只是噪声(写侧本来也拒绝这种形态,读侧过滤只兜手改坏的文件)。
|
|
135
|
+
*/
|
|
136
|
+
export async function readSuites(options = {}) {
|
|
137
|
+
const raw = await readRawDoc(suitesPath(options));
|
|
138
|
+
const version = raw && typeof raw.version === "number" ? raw.version : SUITES_VERSION;
|
|
139
|
+
return { version, suites: normalizeList(raw?.suites) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 整集合覆盖保存(UI「保存」= 一次提交右侧全部套件)。
|
|
144
|
+
*
|
|
145
|
+
* 校验(拒绝档):入参必须是数组;每条必须是对象;名称非空;成员非空;
|
|
146
|
+
* 非空 id 不得重复;总数不超 SUITES_MAX。任一不过 → 抛错且不写盘(原文件保持不变)。
|
|
147
|
+
* 补全:空 id 分配新 id(createId 可注入,测试用确定性实现);缺 createdAt 补 now。
|
|
148
|
+
* updatedAt 由调用方决定(传了就落,没传落 now)——同 payload 重放得到同结果,写侧幂等。
|
|
149
|
+
*
|
|
150
|
+
* 返回写入后的 { version, suites }:UI 必须用它刷新本地副本(新 id 只在这里出现)。
|
|
151
|
+
*/
|
|
152
|
+
export async function saveSuites(suites, options = {}) {
|
|
153
|
+
if (!Array.isArray(suites)) throw new Error("套件集合必须是数组");
|
|
154
|
+
if (suites.length > SUITES_MAX) throw new Error(`套件数量超过上限 ${SUITES_MAX} 个`);
|
|
155
|
+
const createId = typeof options.createId === "function" ? options.createId : defaultCreateId;
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const seenIds = new Set();
|
|
158
|
+
const list = suites.map((raw, index) => {
|
|
159
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
160
|
+
throw new Error(`第 ${index + 1} 条套件不是对象`);
|
|
161
|
+
}
|
|
162
|
+
const suite = normalizeSuite(raw);
|
|
163
|
+
if (!suite.name) throw new Error(`第 ${index + 1} 条套件缺少名称`);
|
|
164
|
+
if (!suite.members.length) throw new Error(`套件「${suite.name}」没有成员`);
|
|
165
|
+
const id = suite.id || String(createId(index) || "").trim();
|
|
166
|
+
if (!id) throw new Error(`第 ${index + 1} 条套件无法分配 id`);
|
|
167
|
+
if (seenIds.has(id)) throw new Error(`套件 id 重复:${id}`);
|
|
168
|
+
seenIds.add(id);
|
|
169
|
+
return {
|
|
170
|
+
...suite,
|
|
171
|
+
id,
|
|
172
|
+
createdAt: suite.createdAt || now,
|
|
173
|
+
updatedAt: suite.updatedAt || now,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
const path = suitesPath(options);
|
|
177
|
+
await writeAtomic(path, { version: SUITES_VERSION, suites: list });
|
|
178
|
+
return { version: SUITES_VERSION, suites: list };
|
|
179
|
+
}
|