@xxxyz/dsh-mcp-manager 2.1.4 → 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.md CHANGED
@@ -16,12 +16,12 @@
16
16
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js)](package.json)
17
17
  [![GitHub](https://img.shields.io/badge/GitHub-xxxyz%2FDeepSeekHarness--MCP--Manager-181717?logo=github)](https://github.com/xxxyz/DeepSeekHarness-MCP-Manager)
18
18
  [![dsh.market](https://img.shields.io/badge/dsh.market-%E2%9C%93-3fb950)](https://dsh.market)
19
- [![awesome-dsh-plugin](https://img.shields.io/badge/awesome--dsh--plugin-%E5%BE%85%E5%90%88%E5%B9%B6-ffd93d)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/2078)
19
+ [![awesome-dsh-plugin](https://img.shields.io/badge/awesome--dsh--plugin-%E5%B7%B2%E6%94%B6%E5%BD%95-3fb950)](https://awesome-dsh-plugin.com)
20
20
 
21
21
  </div>
22
22
 
23
23
  <div align="center">
24
- 🛒 已收录于 <a href="https://dsh.market"><b>dsh.market</b></a> · 已提交 <a href="https://awesome-dsh-plugin.com"><b>awesome-dsh-plugin.com</b></a> 官方列表收录(<a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/2078"><b>PR #2078</b></a> 待合并)
24
+ 🛒 已收录于 <a href="https://dsh.market"><b>dsh.market</b></a> <a href="https://awesome-dsh-plugin.com"><b>awesome-dsh-plugin.com</b></a> 官方插件列表(<a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/2078"><b>PR #2078</b></a> 已合并)
25
25
  </div>
26
26
 
27
27
  <div align="center">
@@ -115,8 +115,8 @@ dsh plugin --profile web remove @xxxyz/dsh-mcp-manager
115
115
 
116
116
  打开 **设置 → Skills 管理**:
117
117
 
118
- - **浏览 / 搜索**:列出 DSH 全部技能,按来源分组(项目级 / 运行时 / 自定义 / 用户级 / 内置 / 插件自带),组内按 provider 折叠;搜索框实时过滤。
119
- - **启用 / 停用**:一键切换任意技能的启用状态——通过 rank-0 override provider(`dsh-mcp-manager-override`)实现,任何来源层级(含项目级)都能禁用。
118
+ - **浏览 / 搜索**:列出 DSH 全部技能,按来源分组(项目级 / 运行时 / 自定义 / 用户级 / 内置 / 插件自带),组内按 provider 折叠;搜索框实时过滤。`~/.dsh/skills/` 下的用户级技能(2.2.0+)同样可见——即使官方 scoped 层不向无 scope 查询暴露它们。
119
+ - **启用 / 停用**:一键切换任意技能的启用状态——通过 rank-0 override provider(`dsh-mcp-manager-override`)实现,任何来源层级(含项目级与用户级文件系统技能)都能禁用。
120
120
  - **持久化**:停用状态写入 `<profileDir>/dsh-skill-manager.json`,重启后保留;改动经 HMR 即时生效。
121
121
 
122
122
  ## ⚙️ 配置
@@ -128,6 +128,8 @@ dsh plugin --profile web remove @xxxyz/dsh-mcp-manager
128
128
  | `version` | loader 行 `config.version`,仅用于触发 HMR 重应用;官方通道安装下由 bundle 自动管理,无需手动修改。 |
129
129
  | `token` | **可选**访问令牌(写操作鉴权,纵深防御)。设置后写操作(增删改/启停/重启/导入导出/技能停用)须带 `x-dsh-token: <token>` 头;设置页提供令牌输入框(保存在浏览器 localStorage)。也可用环境变量 `DSH_MCP_MANAGER_TOKEN` 配置。默认关闭。 |
130
130
 
131
+ > **为什么需要 token?** 插件的 CSRF 防护只拦"跨站浏览器请求"——它假设 DSH web 只监听本机(`127.0.0.1`)。一旦你通过端口转发、`dsh-web-lan-access` 类插件或反向代理把 3080 端口暴露到局域网/公网,**任何能访问该端口的人都直接获得完整写权限**:可以新增/修改 MCP 服务器,而 `stdio` 服务的 `command` 字段可填任意可执行文件——等同于**远程任意代码执行**。token 就是为这种暴露场景加的最后一道闸:没有密钥就无法做任何写操作,即使端口被暴露也不能注入命令。本地单机使用不需要配置。
132
+
131
133
  loader 行必须为 **`insert` 块**形式(DSH patch 方言中普通 `- id:` 行只是对已存在条目的覆盖,无法新增插件):
132
134
 
133
135
  ```yaml
package/README_EN.md CHANGED
@@ -17,12 +17,12 @@
17
17
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js)](package.json)
18
18
  [![GitHub](https://img.shields.io/badge/GitHub-xxxyz%2FDeepSeekHarness--MCP--Manager-181717?logo=github)](https://github.com/xxxyz/DeepSeekHarness-MCP-Manager)
19
19
  [![dsh.market](https://img.shields.io/badge/dsh.market-%E2%9C%93-3fb950)](https://dsh.market)
20
- [![awesome-dsh-plugin](https://img.shields.io/badge/awesome--dsh--plugin-pending-ffd93d)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/2078)
20
+ [![awesome-dsh-plugin](https://img.shields.io/badge/awesome--dsh--plugin-listed-3fb950)](https://awesome-dsh-plugin.com)
21
21
 
22
22
  </div>
23
23
 
24
24
  <div align="center">
25
- 🛒 Listed on <a href="https://dsh.market"><b>dsh.market</b></a> · submitted to <a href="https://awesome-dsh-plugin.com"><b>awesome-dsh-plugin.com</b></a> (<a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/2078"><b>PR #2078</b></a> pending merge)
25
+ 🛒 Listed on <a href="https://dsh.market"><b>dsh.market</b></a> and the <a href="https://awesome-dsh-plugin.com"><b>awesome-dsh-plugin.com</b></a> official plugin list (<a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/2078"><b>PR #2078</b></a> merged)
26
26
  </div>
27
27
 
28
28
  <div align="center">
@@ -116,8 +116,8 @@ Open **Settings → MCP 管理**:
116
116
 
117
117
  Open **Settings → Skills 管理**:
118
118
 
119
- - **Browse / Search**: lists every DSH skill grouped by source (project / runtime / custom / user / built-in / plugin-bundled), collapsed per provider; the search box filters live.
120
- - **Enable / Disable**: toggle any skill in one click — implemented with a rank-0 override provider (`dsh-mcp-manager-override`), so every source level (including project-level) can be disabled.
119
+ - **Browse / Search**: lists every DSH skill grouped by source (project / runtime / custom / user / built-in / plugin-bundled), collapsed per provider; the search box filters live. User-level skills under `~/.dsh/skills/` (2.2.0+) are listed too — even though the official scoped layer never exposes them to scope-less queries.
120
+ - **Enable / Disable**: toggle any skill in one click — implemented with a rank-0 override provider (`dsh-mcp-manager-override`), so every source level (including project-level and user-level filesystem skills) can be disabled.
121
121
  - **Persistence**: disabled state is written to `<profileDir>/dsh-skill-manager.json`, survives restarts, and applies live via HMR.
122
122
 
123
123
  ## ⚙️ Configuration
@@ -129,6 +129,8 @@ Configuration of the plugin itself on its loader row:
129
129
  | `version` | The loader row's `config.version`, only used to trigger an HMR re-apply; auto-managed by the bundle channel — no manual edits needed. |
130
130
  | `token` | **Optional** access token (write-op auth, defense in depth). When set, state-changing ops (add/edit/remove/enable/restart/import/export/skill-toggle) require the `x-dsh-token: <token>` header; the settings pages provide a token input (stored in browser localStorage). Can also be set via the `DSH_MCP_MANAGER_TOKEN` env var. Off by default. |
131
131
 
132
+ > **Why is the token needed?** The plugin's CSRF protection only blocks *cross-site browser* requests — it assumes DSH web listens on localhost (`127.0.0.1`) only. Once you expose port 3080 to a LAN or the public internet (port forwarding, a `dsh-web-lan-access`-style plugin, or a reverse proxy), **anyone who can reach the port gets full write access**: they can add/modify MCP servers, and a `stdio` server's `command` field accepts any executable — i.e. **remote arbitrary code execution**. The token is the last gate for exactly this exposure scenario: without the secret, no write op succeeds even when the port is exposed, so commands cannot be injected. Local single-machine use does not need it.
133
+
132
134
  The loader row must be an **`insert` block** (in DSH's patch dialect a plain `- id:` row only overrides existing entries and can never add a new plugin):
133
135
 
134
136
  ```yaml
package/lib/index.js CHANGED
@@ -115,7 +115,7 @@ export default {
115
115
  for (const name of await readState()) {
116
116
  if (overrideSkills.has(name))
117
117
  continue;
118
- const def = await ctx.skills.get(name);
118
+ const def = (await ctx.skills.get(name)) || (await findUserSkill(name));
119
119
  if (!def)
120
120
  continue;
121
121
  overrideSkills.set(name, { ...def, provider: OVERRIDE_PROVIDER, invocation: { modelInvocable: false, userInvocable: false } });
@@ -126,10 +126,154 @@ export default {
126
126
  }
127
127
  ;
128
128
  ctx.on('skills/change', () => { void ensureRestored(); });
129
+ // User-level skills (~/.dsh/skills/*/SKILL.md) are discovered by
130
+ // dsh-skill-filesystem in the agent-preset SCOPED layer, which a scope-less
131
+ // ctx.skills.snapshot({}) (global layer only) never reaches — so the
132
+ // management page would silently hide them. Scan the directory ourselves
133
+ // and merge into skill-list. Toggling still goes through the rank-0
134
+ // override above: it shadows by NAME, so it works across layers — but
135
+ // ctx.skills.get(name) is also scope-less, hence the scan fallback when
136
+ // synthesizing the override definition. DSH_MCP_MANAGER_SKILLS_DIR
137
+ // overrides the directory (used by tests).
138
+ function userSkillsDir(home, sep) {
139
+ const env = process.env.DSH_MCP_MANAGER_SKILLS_DIR;
140
+ return env && env.trim() ? env.trim() : home + sep + 'skills';
141
+ }
142
+ // Minimal SKILL.md frontmatter reader that MIRRORS the official
143
+ // dsh-skill-filesystem parse rules (so this page never lists a skill the
144
+ // / menu would reject):
145
+ // * `name` and `description` are REQUIRED (non-empty) — no dir-name fallback
146
+ // * legacy camelCase keys (`userInvocable` / `modelInvocable`) are REJECTED
147
+ // * booleans accept true/1/yes/on vs false/0/no/off (case-insensitive);
148
+ // any other value invalidates the whole entry
149
+ // Returns null for anything the official provider would skip.
150
+ function parseFrontmatterBoolean(val) {
151
+ const v = val.toLowerCase();
152
+ if (v === 'true' || v === '1' || v === 'yes' || v === 'on')
153
+ return true;
154
+ if (v === 'false' || v === '0' || v === 'no' || v === 'off')
155
+ return false;
156
+ return undefined;
157
+ }
158
+ function parseSkillFile(raw) {
159
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
160
+ if (!m)
161
+ return null;
162
+ const fields = new Map();
163
+ for (const line of m[1].split(/\r?\n/)) {
164
+ const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim());
165
+ if (!kv)
166
+ continue;
167
+ fields.set(kv[1], kv[2].trim().replace(/^['"]|['"]$/g, ''));
168
+ }
169
+ if (fields.has('userInvocable') || fields.has('modelInvocable'))
170
+ return null;
171
+ const name = fields.get('name');
172
+ const description = fields.get('description');
173
+ if (!name || description === undefined || description === '')
174
+ return null;
175
+ const fm = { name, description };
176
+ const wtu = fields.get('whenToUse') ?? fields.get('when-to-use');
177
+ if (wtu)
178
+ fm.whenToUse = wtu;
179
+ const ui = fields.get('user-invocable');
180
+ if (ui !== undefined) {
181
+ const b = parseFrontmatterBoolean(ui);
182
+ if (b === undefined)
183
+ return null;
184
+ fm.userInvocable = b;
185
+ }
186
+ const dmi = fields.get('disable-model-invocation');
187
+ if (dmi !== undefined) {
188
+ const b = parseFrontmatterBoolean(dmi);
189
+ if (b === undefined)
190
+ return null;
191
+ fm.disableModelInvocation = b;
192
+ }
193
+ const content = raw.slice(m[0].length).replace(/^\r?\n+/, '');
194
+ return { fm, content };
195
+ }
196
+ async function scanUserSkills() {
197
+ let dir;
198
+ let sep;
199
+ try {
200
+ const p = await ensurePaths();
201
+ sep = p.home.indexOf('\\') >= 0 ? '\\' : '/';
202
+ dir = userSkillsDir(p.home, sep);
203
+ }
204
+ catch (e) {
205
+ return [];
206
+ }
207
+ let entries = [];
208
+ try {
209
+ entries = await fs.listDir(await fs.resolve(dir));
210
+ }
211
+ catch (e) {
212
+ return [];
213
+ }
214
+ const rows = [];
215
+ const seen = new Set();
216
+ for (const ent of entries) {
217
+ const nm = String(ent.name || '');
218
+ if (!nm || nm === '.system')
219
+ continue;
220
+ // Official parity (dsh-skill-filesystem discoverRoot): a directory entry
221
+ // points at `<dir>/SKILL.md`; a flat `*.md` file in the root is itself a
222
+ // skill. We cannot stat entry types through the fs service, so try the
223
+ // directory form first and fall back to the flat form for `.md` names.
224
+ let path = dir + sep + nm + sep + 'SKILL.md';
225
+ let directory = dir + sep + nm;
226
+ let raw = '';
227
+ try {
228
+ raw = await fs.readText(await fs.resolve(path));
229
+ }
230
+ catch (e) {
231
+ if (!nm.endsWith('.md'))
232
+ continue;
233
+ path = dir + sep + nm;
234
+ directory = dir;
235
+ try {
236
+ raw = await fs.readText(await fs.resolve(path));
237
+ }
238
+ catch (e2) {
239
+ continue;
240
+ }
241
+ }
242
+ const parsed = parseSkillFile(raw);
243
+ if (!parsed) {
244
+ console.warn(`[dsh-mcp-manager] user skill ${path} ignored: missing/invalid frontmatter (name+description required, kebab-case name, official boolean grammar)`);
245
+ continue;
246
+ }
247
+ const { fm, content } = parsed;
248
+ if (!SKILL_NAME_RE.test(fm.name))
249
+ continue;
250
+ if (seen.has(fm.name))
251
+ continue;
252
+ seen.add(fm.name);
253
+ rows.push({
254
+ name: fm.name,
255
+ description: fm.description,
256
+ ...(fm.whenToUse !== undefined ? { whenToUse: fm.whenToUse } : {}),
257
+ invocation: { modelInvocable: fm.disableModelInvocation !== true, userInvocable: fm.userInvocable !== false },
258
+ source: 'user-dsh',
259
+ provider: 'filesystem',
260
+ resourceBase: { kind: 'directory', path: directory },
261
+ path,
262
+ content,
263
+ });
264
+ }
265
+ return rows;
266
+ }
267
+ async function findUserSkill(name) {
268
+ return (await scanUserSkills()).find((s) => s.name === name);
269
+ }
129
270
  async function skillList() {
130
271
  await ensureRestored();
131
272
  const snap = await ctx.skills.snapshot({});
132
- return { ok: true, skills: snap.skills, complete: snap.complete !== false };
273
+ const seen = new Set(snap.skills.map((s) => s.name));
274
+ const extra = (await scanUserSkills()).filter((s) => !seen.has(s.name));
275
+ const skills = snap.skills.concat(extra).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
276
+ return { ok: true, skills, complete: snap.complete !== false };
133
277
  }
134
278
  async function skillToggle(args) {
135
279
  const name = String(args.name || '').trim();
@@ -148,7 +292,7 @@ export default {
148
292
  }
149
293
  else {
150
294
  if (!overrideSkills.has(name)) {
151
- const def = await ctx.skills.get(name);
295
+ const def = (await ctx.skills.get(name)) || (await findUserSkill(name));
152
296
  if (!def)
153
297
  return { ok: false, error: '技能不存在: ' + name };
154
298
  overrideSkills.set(name, { ...def, provider: OVERRIDE_PROVIDER, invocation: { modelInvocable: false, userInvocable: false } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xxxyz/dsh-mcp-manager",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "DSH-standard MCP manager plugin: Settings UI + HTTP API + model-facing mcp_manager_* tools. Install with one command: dsh plugin --profile web add @xxxyz/dsh-mcp-manager@latest",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",