dsh-armory 2.3.0 → 2.3.1

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.
@@ -82,12 +82,18 @@ function dirnameOf(path) {
82
82
  return cut > 0 ? path.slice(0, cut) : path;
83
83
  }
84
84
 
85
- /** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式)。 */
85
+ /** 唯一临时名 + rename 原子写(与 wb-registry.writeAtomic 同一模式):固定 `.staging`
86
+ * 在并发登记时会互相消费对方的临时文件,rename 覆盖已存在文件所以无需先 rm。 */
86
87
  async function writeAtomic(path, doc) {
87
88
  await mkdir(dirnameOf(path), { recursive: true });
88
- await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
89
- await rm(path, { force: true });
90
- await rename(`${path}.staging`, path);
89
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
90
+ try {
91
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
92
+ await rename(tmp, path);
93
+ } catch (error) {
94
+ await rm(tmp, { force: true }).catch(() => {});
95
+ throw error;
96
+ }
91
97
  }
92
98
 
93
99
  /** 读全部条目(合并投影:文件缺失/损坏 = 空状态)。永不抛错。 */
package/lib/wb-market.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // - 网络只发生在 Host 侧(浏览器不直连腾讯/SkillHub 域名):面板经由 mcpManager Remote
15
15
  // 命名空间调用本文件提供的方法,与既有预览/导入同一信封。
16
16
  // - 「能装」的标准从严:MCP 只收 streamable-http 与无 preAuth 的 stdio;CLI 型(cli.json)
17
- // 与 server-side 鉴权只展示不装——宁可少,不可错(与 mcp-catalog.js 同一纪律)。
17
+ // 与 server-side 鉴权只展示不装——宁可少,不可错。
18
18
  // - 技能安装逐文件校验 sha256(清单自带哈希)后再落盘,目标目录按 SkillHub 的
19
19
  // canonicalName 命名(`@tencent-adm/tencent-docs` → `tencent-adm__tencent-docs`),
20
20
  // 与 dsh-skill-market 的命名并存不冲突(本插件不带 @ 前缀保留字)。
@@ -44,7 +44,8 @@ const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-a
44
44
  const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
45
45
  // ZIP 落盘缓存目录(~/.dsh 由 DSH 自己管理,这里只在它下面开一个子目录)。
46
46
  function cacheDir() {
47
- return join(homedir(), ".dsh", "wb-market");
47
+ // 测试隔离口(惯例同 wb-registry 的 DSH_ARMORY_HOME):设置时缓存根指到临时目录。
48
+ return process.env.DSH_ARMORY_MARKET_HOME || join(homedir(), ".dsh", "wb-market");
48
49
  }
49
50
  function zipPath() {
50
51
  return join(cacheDir(), "connectors-config.zip");
@@ -76,6 +77,20 @@ async function fetchWithLimits(url, { timeoutMs = 30_000 } = {}) {
76
77
  // ── 连接器 ZIP:下载 + 缓存 ─────────────────────────────────────────────
77
78
  // meta.json 记录远端 ETag;命中就不重复下 24MB。ZIP 本体损坏时丢弃缓存重新拉一次。
78
79
  let cachedZip = null; // { zip: ZipReader, loadedFrom: "memory"|"disk"|"network" }
80
+ // 连接器图标的进程内缓存(id → data URL,null = 探过且没有)。换新 ZIP 时整体作废,
81
+ // 清空点在 loadConnectorZip 安装新包的两处。
82
+ const connectorIconCache = new Map();
83
+ /** 缓存正文用临时文件 + rename 落盘:写一半被杀只会留下无人读的 .tmp,不会留下截断的 ZIP。 */
84
+ async function writeCacheFile(path, data) {
85
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
86
+ try {
87
+ await writeFile(tmp, data);
88
+ await rename(tmp, path);
89
+ } catch (error) {
90
+ await rm(tmp, { force: true }).catch(() => {});
91
+ throw error;
92
+ }
93
+ }
79
94
  async function loadConnectorZip() {
80
95
  if (cachedZip) return cachedZip;
81
96
  await mkdir(cacheDir(), { recursive: true });
@@ -94,32 +109,39 @@ async function loadConnectorZip() {
94
109
  if (buffer.length > 64 * 1024 * 1024) throw new Error("连接器市场包超过 64MB 上限,疑似异常响应");
95
110
  return { buffer, etag: response.headers.get("etag") || "" };
96
111
  };
97
- let zip = null;
98
- if (meta.etag) {
99
- // 有缓存:先验证磁盘上的 ZIP 还能用,能用就 304 探测上游;网络挂了就继续用旧缓存。
100
- try {
101
- const local = await readFile(zipPath());
102
- zip = new ZipReader(local);
103
- const fresh = await download(meta.etag);
104
- if (fresh === null) {
105
- cachedZip = { zip, loadedFrom: "disk" };
106
- return cachedZip;
107
- }
108
- zip = new ZipReader(fresh.buffer);
109
- await writeFile(zipPath(), fresh.buffer);
110
- await writeFile(metaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
111
- cachedZip = { zip, loadedFrom: "network" };
112
- return cachedZip;
113
- } catch {
114
- zip = null;
115
- }
112
+ const persistNetwork = async (fresh) => {
113
+ // ZipReader 构造即校验 central directory:坏包在这里抛,不会污染缓存。
114
+ const zip = new ZipReader(fresh.buffer);
115
+ await writeCacheFile(zipPath(), fresh.buffer);
116
+ await writeCacheFile(metaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
117
+ connectorIconCache.clear();
118
+ cachedZip = { zip, loadedFrom: "network" };
119
+ return cachedZip;
120
+ };
121
+ // 磁盘 ZIP 先验证好备用:它是 304(内容未变)与断网两条路的共同回落——
122
+ // 「网络挂了就继续用旧缓存」靠它兑现。验证失败只回落到全量下载,不抛出去。
123
+ let diskZip = null;
124
+ try {
125
+ diskZip = new ZipReader(await readFile(zipPath()));
126
+ } catch {
127
+ diskZip = null;
116
128
  }
117
- const fresh = await download(meta.etag || "");
118
- zip = new ZipReader(fresh.buffer);
119
- await writeFile(zipPath(), fresh.buffer);
120
- await writeFile(metaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
121
- cachedZip = { zip, loadedFrom: "network" };
122
- return cachedZip;
129
+ try {
130
+ // 只在磁盘 ZIP 有效时才带 etag:本地包损坏/缺失而 etag 命中 304 的话,
131
+ // 手上没有任何正文可用(旧实现此时会拿 null 去取 buffer,崩成 TypeError 且无自愈路径)。
132
+ const fresh = await download(diskZip && meta.etag ? meta.etag : "");
133
+ if (fresh) return await persistNetwork(fresh);
134
+ // 304 = 服务器内容与 etag 一致 = 磁盘缓存就是权威副本。
135
+ cachedZip = { zip: diskZip, loadedFrom: "disk" };
136
+ return cachedZip;
137
+ } catch {
138
+ // 网络失败 / 上游包异常:有磁盘缓存就回落到它,绝不因为刷新失败把市场打空。
139
+ }
140
+ if (diskZip) {
141
+ cachedZip = { zip: diskZip, loadedFrom: "disk" };
142
+ return cachedZip;
143
+ }
144
+ throw new Error("连接器市场不可用:下载失败且本地没有有效的缓存副本");
123
145
  }
124
146
 
125
147
  // ── 连接器目录投影 ─────────────────────────────────────────────────────
@@ -315,6 +337,48 @@ export async function wbConnectorPreview(connectorId) {
315
337
  return { connectorId: id, mcpServers: servers };
316
338
  }
317
339
 
340
+ // ── 连接器图标 ─────────────────────────────────────────────────────────
341
+ // 索引 connectors.json 里没有 icon 字段,但 ZIP 顶层有 icons/ 目录,文件名就是
342
+ // 连接器 id(2026-10-24 实测 443/443 全覆盖),所以按 id 猜扩展名,svg 优先于位图
343
+ // (矢量在 30px 头像位最清晰)。图标只在卡片进视口时被调到这里,单条平均 27KB;
344
+ // 走 data URL 内联而不是宿主文件路径——面板是 webview,读不到本机文件。
345
+ const CONNECTOR_ICON_EXTS = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
346
+ const CONNECTOR_ICON_MIME = {
347
+ ".svg": "image/svg+xml",
348
+ ".png": "image/png",
349
+ ".jpg": "image/jpeg",
350
+ ".jpeg": "image/jpeg",
351
+ ".webp": "image/webp",
352
+ ".gif": "image/gif",
353
+ };
354
+
355
+ /** 某条连接器的图标(data URL 形式)。没有/包没就绪都返回 ok:false,UI 回落首字母。 */
356
+ export async function wbConnectorIcon(connectorId) {
357
+ const id = String(connectorId || "").trim();
358
+ const missing = { ok: false, connectorId: id, dataUrl: "" };
359
+ // id 来自我们自己的目录投影,仍防一层路径注入(query 由 RPC 进来,不信任输入)。
360
+ if (!id || /[\\/\s]/.test(id) || id.includes("..")) return missing;
361
+ if (connectorIconCache.has(id)) {
362
+ const dataUrl = connectorIconCache.get(id);
363
+ return dataUrl ? { ok: true, connectorId: id, dataUrl } : missing;
364
+ }
365
+ let zip;
366
+ try {
367
+ ({ zip } = await loadConnectorZip());
368
+ } catch {
369
+ // 包没就绪(如首次下载失败):不缓存这条「没有」,卡片下次进视口还能再试。
370
+ return missing;
371
+ }
372
+ const ext = CONNECTOR_ICON_EXTS.find((candidate) => zip.has(`icons/${id}${candidate}`));
373
+ if (!ext) {
374
+ connectorIconCache.set(id, null);
375
+ return missing;
376
+ }
377
+ const dataUrl = `data:${CONNECTOR_ICON_MIME[ext]};base64,${zip.extract(`icons/${id}${ext}`).toString("base64")}`;
378
+ connectorIconCache.set(id, dataUrl);
379
+ return { ok: true, connectorId: id, dataUrl };
380
+ }
381
+
318
382
  /**
319
383
  * 装前 401 探测:对 streamable-http 型 server 发一次 MCP initialize JSON-RPC,
320
384
  * 服务器明确回 401/403 就中止安装。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
@@ -462,6 +526,14 @@ export async function wbSkillCatalog(payload = {}) {
462
526
  name: String(skill.name || skill.slug || ""),
463
527
  namespace: String(skill.namespace?.handle || ""),
464
528
  canonical: String(skill.namespace?.canonicalName || ""),
529
+ // 「人」的字段:发布方显示名、作品图标、公开详情页、上架时间与认领状态。
530
+ // 这些字段列表接口本来就有,不额外发请求——卡片上的人味信号零成本。
531
+ ownerName: String(skill.ownerName || skill.namespace?.displayName || skill.namespace?.handle || ""),
532
+ iconUrl: String(skill.iconUrl || ""),
533
+ homepage: String(skill.homepage || ""),
534
+ createdAt: Number(skill.created_at) || 0,
535
+ claimState: String(skill.claim_state || ""),
536
+ source: String(skill.source || ""),
465
537
  description: String(skill.description_zh || skill.description || ""),
466
538
  descriptionEn: String(skill.description_en || ""),
467
539
  downloads: Number(skill.downloads) || 0,
@@ -1,12 +1,16 @@
1
1
  // 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
2
2
  //
3
3
  // 职责(对应设计 §8.3,类图 MarketRegistry):
4
- // - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
5
- // 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
6
- // codebuddy-plugins-official 插件源;首读时把默认项落盘。
4
+ // - 注册表文件不存在或损坏时,回落到**内置默认一条**:连接器源(与
5
+ // lib/wb-market.js:27 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相);
6
+ // 首读时把默认项落盘。
7
7
  // - 合并投影:内置默认永远在场(文件内同 id 条目可覆盖其字段),用户自定义条目追加其后。
8
8
  // - 增删改一律 staging + rename 原子替换(沿用 wbSkillInstall 的 staging 模式)。
9
9
  //
10
+ // 插件市场从 2.3.0 起只保留 DSH 社区源(awesome-dsh-plugins),CodeBuddy 官方 ZIP
11
+ // 插件源已整体移除;旧 marketplaces.json 里的 kind="plugins" 条目读侧直接丢弃,
12
+ // 写侧也顺手清掉,避免它们借 normalize 回落成一条假的连接器源。
13
+ //
10
14
  // 纯数据层:不做任何网络请求,不依赖 Remote/UI。测试可通过 options.rootDir 或
11
15
  // 环境变量 DSH_ARMORY_HOME 把存储根目录指到临时目录,绝不碰真实 ~/.dsh。
12
16
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
@@ -30,9 +34,9 @@ export function registryPath(options = {}) {
30
34
  }
31
35
 
32
36
  /**
33
- * 内置默认市场条目(代码内声明,首读落盘)。顺序即 sourceFor 的优先序:
34
- * 1. connectors → 现有 connectors-config.zip(WorkBuddy 连接器市场分发地址)
35
- * 2. plugins → codebuddy-plugins-official(清单实测在 .codebuddy-plugin/marketplace.json)
37
+ * 内置默认市场条目(代码内声明,首读落盘):
38
+ * connectors → 现有 connectors-config.zip(WorkBuddy 连接器市场分发地址)
39
+ * 插件市场不在这里登记:它只有 DSH 社区一个源,由 lib/wb-awesome-market.js 直连。
36
40
  */
37
41
  export function builtinMarketplaces() {
38
42
  return [
@@ -49,26 +53,13 @@ export function builtinMarketplaces() {
49
53
  addedAt: 0,
50
54
  lastUpdated: 0,
51
55
  },
52
- {
53
- id: "codebuddy-plugins-official",
54
- kind: "plugins",
55
- manifestName: "codebuddy-plugins-official",
56
- type: "zip",
57
- source: {
58
- url: "https://download.codebuddy.cn/plugin-marketplace/codebuddy-plugins-official-4b9366ac-f7e5-4cef-b60a-99a5229ce2b2.zip",
59
- path: "",
60
- },
61
- manifestPath: ".codebuddy-plugin/marketplace.json",
62
- autoUpdate: false,
63
- isBuiltIn: true,
64
- description: "CodeBuddy 官方插件市场",
65
- addedAt: 0,
66
- lastUpdated: 0,
67
- },
68
56
  ];
69
57
  }
70
58
 
71
- const KINDS = new Set(["connectors", "plugins"]);
59
+ const KINDS = new Set(["connectors"]);
60
+ // 已下线的市场类型:读侧丢弃、写侧清除。绝不能让它回落到 connectors——
61
+ // 那会把一条 CodeBuddy 插件源伪装成连接器源。
62
+ const REMOVED_KINDS = new Set(["plugins"]);
72
63
 
73
64
  /** 字符串字段防御读取:非字符串一律回落默认值。 */
74
65
  function asString(value, fallback = "") {
@@ -115,12 +106,19 @@ async function readRawDoc(path) {
115
106
  }
116
107
  }
117
108
 
118
- /** staging + rename 原子写:先写 `<file>.staging`,再删旧文件换入(Windows rename 不覆盖)。 */
109
+ /** 唯一临时名 + rename 原子写(Windows 的 rename 覆盖已存在文件,无需先 rm)。固定
110
+ * `<file>.staging` 在并发写时会互相踩踏——后写者会消费掉先写者的 staging,极端时序
111
+ * 甚至把先写者刚提交的正本删掉,注册表条目就此丢失。 */
119
112
  async function writeAtomic(path, doc) {
120
113
  await mkdir(armoryPathDirname(path), { recursive: true });
121
- await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
122
- await rm(path, { force: true });
123
- await rename(`${path}.staging`, path);
114
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
115
+ try {
116
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
117
+ await rename(tmp, path);
118
+ } catch (error) {
119
+ await rm(tmp, { force: true }).catch(() => {});
120
+ throw error;
121
+ }
124
122
  }
125
123
 
126
124
  /** 拆出目录部分(避免为 dirname 多引一个依赖,路径既有 / 也有 \ 的 Windows 形态)。 */
@@ -131,7 +129,7 @@ function armoryPathDirname(path) {
131
129
 
132
130
  /**
133
131
  * 读注册表并返回合并投影:
134
- * - 文件不存在 / 解析失败 / marketplaces 非数组 → 内置默认两条(并尽力落盘);
132
+ * - 文件不存在 / 解析失败 / marketplaces 非数组 → 内置默认一条(并尽力落盘);
135
133
  * - 文件内与内置默认同 id 的条目**覆盖**默认项字段;
136
134
  * - 其余文件条目按原顺序追加在默认项之后。
137
135
  * 返回 { version, marketplaces },永不抛错。
@@ -140,7 +138,7 @@ export async function readRegistry(options = {}) {
140
138
  const path = registryPath(options);
141
139
  const raw = await readRawDoc(path);
142
140
  const fileEntries = Array.isArray(raw?.marketplaces)
143
- ? raw.marketplaces.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
141
+ ? raw.marketplaces.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry) && !REMOVED_KINDS.has(entry.kind))
144
142
  : null;
145
143
 
146
144
  if (fileEntries === null) {
@@ -173,12 +171,15 @@ export async function readRegistry(options = {}) {
173
171
  * 返回写入后的合并投影。
174
172
  */
175
173
  export async function upsertMarketplace(entry, options = {}) {
174
+ if (REMOVED_KINDS.has(entry?.kind)) {
175
+ throw new Error("CodeBuddy 插件市场已移除,注册表不再接受 plugins 类型的市场源");
176
+ }
176
177
  const normalized = normalizeEntry(entry);
177
178
  if (!normalized.id) throw new Error("市场条目缺少 id,无法登记");
178
179
  const path = registryPath(options);
179
180
  const raw = await readRawDoc(path);
180
181
  const list = Array.isArray(raw?.marketplaces)
181
- ? raw.marketplaces.filter((item) => item && typeof item === "object" && !Array.isArray(item))
182
+ ? raw.marketplaces.filter((item) => item && typeof item === "object" && !Array.isArray(item) && !REMOVED_KINDS.has(item.kind))
182
183
  : [];
183
184
  const now = Date.now();
184
185
  const existingIndex = list.findIndex((item) => normalizeEntry(item).id === normalized.id);
@@ -210,7 +211,7 @@ export async function removeMarketplace(id, options = {}) {
210
211
  return readRegistry(options); // 文件本就没有用户条目,直接回落投影
211
212
  }
212
213
  const list = raw.marketplaces.filter(
213
- (item) => !(item && typeof item === "object" && normalizeEntry(item).id === target),
214
+ (item) => !(item && typeof item === "object" && (normalizeEntry(item).id === target || REMOVED_KINDS.has(item.kind))),
214
215
  );
215
216
  await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: list });
216
217
  return readRegistry(options);
@@ -389,6 +389,27 @@ export const WHATS_NEW_EN = {
389
389
  where: 'Tool Tailor -> note under the skill table',
390
390
  },
391
391
  ],
392
+ '2.3.1': [
393
+ {
394
+ text: '**Fixed two dead ends in the market caches; offline degradation now keeps its promise**: with a corrupted or cleaned local cache and an unchanged upstream, the expert catalog and connector market crashed on an empty 304 response and failed on every call; offline, a perfectly good disk cache was thrown away and the panel went empty. Both cases now degrade correctly: a broken cache triggers a full re-download without the stale ETag, and a network outage keeps serving the disk cache (loadedFrom=disk)',
395
+ where: 'Market / Experts',
396
+ },
397
+ {
398
+ text: '**Network data is validated before it is written**: catalog / ZIP responses must pass a structure check first (a missing experts array or a corrupt ZIP directory counts as a download failure), so a proxy interstitial that parses as JSON can no longer overwrite a good cache; writes go through a unique temp file + rename, so an interrupted run never leaves a truncated cache body',
399
+ where: 'Market / Experts',
400
+ },
401
+ {
402
+ text: '**Expert persona path now follows the marketplace manifest**: the manifest-provided promptFile was dropped during projection, so experts whose plugin.json could not be fetched failed with "no persona file path" or installed the wrong persona file; the authoritative path takes effect directly now',
403
+ where: 'Experts -> Detail / Summon',
404
+ },
405
+ {
406
+ text: '**Fixed a write race in local caches**: the multi-market registry, custom suites, install state, community meta and npm downloads now write through a unique temp file, so concurrently returning detail cards no longer trample each other into corrupt files - the worst case previously lost user-defined marketplace entries',
407
+ where: 'Global panel',
408
+ },
409
+ {
410
+ text: 'Awesome cold start no longer discards data fetched from source 2 when source 1 fails; removed 19 one-off developer patch scripts and two dead exports',
411
+ },
412
+ ],
392
413
  };
393
414
 
394
415
  /**
package/lib/whats-new.js CHANGED
@@ -445,4 +445,28 @@ export const WHATS_NEW = [
445
445
  },
446
446
  ],
447
447
  },
448
+ {
449
+ version: '2.3.1',
450
+ items: [
451
+ {
452
+ text: '**修复市场缓存的两条死路,离线降级恢复承诺行为**:本地缓存损坏或被清理而上游未更新时,专家目录与连接器市场会对 304 空响应崩溃并每次调用都失败;断网时又会把完好的磁盘缓存丢弃、整块打空。现在两类场景都正确回落:缓存损坏即不带旧 ETag 全量重下,断网继续用磁盘缓存(loadedFrom=disk)',
453
+ where: '市场 / 专家',
454
+ },
455
+ {
456
+ text: '**网络数据先校验再落盘**:目录 / ZIP 响应先过结构校验(缺 experts 数组、ZIP 目录损坏一律按下载失败处理),代理拦截页之类「可解析但不是清单」的响应不再覆盖好缓存;落盘改为唯一临时文件 + rename 原子写,中断不再留下截断的缓存正文',
457
+ where: '市场 / 专家',
458
+ },
459
+ {
460
+ text: '**专家人设路径以市场清单为准**:目录条目自带的 promptFile 此前被投影丢弃,plugin.json 拉不到的专家会硬报「没有人设文件路径」甚至装错人设文件;现在清单给的权威路径直接生效',
461
+ where: '专家 → 详情 / 召唤',
462
+ },
463
+ {
464
+ text: '**修复本地缓存并发写的竞态**:多市场注册表、自定义套件、安装状态、社区元信息、npm 下载量的写盘改用唯一临时名,多张详情卡同时回来不再互相踩出坏文件——此前极端时序可丢用户自定义市场条目',
465
+ where: '全局面板',
466
+ },
467
+ {
468
+ text: 'awesome 冷启动遇源 1 全链失败不再丢弃源 2 已拉到的数据;清理 19 个开发期一次性补丁脚本与两处已失效的死导出',
469
+ },
470
+ ],
471
+ },
448
472
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.3.0",
4
- "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
3
+ "version": "2.3.1",
4
+ "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & DSH community plugin market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/2026heshao/dsh-armory.git"