@xxxyz/dsh-mcp-manager 2.1.5 → 2.2.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.
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
  ## ⚙️ 配置
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
package/lib/index.js CHANGED
@@ -115,21 +115,210 @@ export default {
115
115
  for (const name of await readState()) {
116
116
  if (overrideSkills.has(name))
117
117
  continue;
118
+ // Migrate pre-fix state entries: user-level skills are file-driven now
119
+ // (the scoped layer ignores global overrides), so rewrite the SKILL.md
120
+ // frontmatter and drop the stale state entry instead of creating an
121
+ // override that cannot shadow them.
122
+ const userDef = await findUserSkill(name);
123
+ if (userDef) {
124
+ await editUserSkillInvocation(name, false);
125
+ changed = true;
126
+ continue;
127
+ }
118
128
  const def = await ctx.skills.get(name);
119
129
  if (!def)
120
130
  continue;
121
131
  overrideSkills.set(name, { ...def, provider: OVERRIDE_PROVIDER, invocation: { modelInvocable: false, userInvocable: false } });
122
132
  changed = true;
123
133
  }
124
- if (changed && skillProviderControl)
125
- skillProviderControl.invalidate();
134
+ if (changed) {
135
+ await saveState([...overrideSkills.keys()]);
136
+ if (skillProviderControl)
137
+ skillProviderControl.invalidate();
138
+ }
126
139
  }
127
140
  ;
128
141
  ctx.on('skills/change', () => { void ensureRestored(); });
142
+ // User-level skills (~/.dsh/skills/*/SKILL.md) are discovered by
143
+ // dsh-skill-filesystem in the agent-preset SCOPED layer, which a scope-less
144
+ // ctx.skills.snapshot({}) (global layer only) never reaches — so the
145
+ // management page would silently hide them. Scan the directory ourselves
146
+ // and merge into skill-list. Toggling still goes through the rank-0
147
+ // override above: it shadows by NAME, so it works across layers — but
148
+ // ctx.skills.get(name) is also scope-less, hence the scan fallback when
149
+ // synthesizing the override definition. DSH_MCP_MANAGER_SKILLS_DIR
150
+ // overrides the directory (used by tests).
151
+ function userSkillsDir(home, sep) {
152
+ const env = process.env.DSH_MCP_MANAGER_SKILLS_DIR;
153
+ return env && env.trim() ? env.trim() : home + sep + 'skills';
154
+ }
155
+ // Minimal SKILL.md frontmatter reader that MIRRORS the official
156
+ // dsh-skill-filesystem parse rules (so this page never lists a skill the
157
+ // / menu would reject):
158
+ // * `name` and `description` are REQUIRED (non-empty) — no dir-name fallback
159
+ // * legacy camelCase keys (`userInvocable` / `modelInvocable`) are REJECTED
160
+ // * booleans accept true/1/yes/on vs false/0/no/off (case-insensitive);
161
+ // any other value invalidates the whole entry
162
+ // Returns null for anything the official provider would skip.
163
+ function parseFrontmatterBoolean(val) {
164
+ const v = val.toLowerCase();
165
+ if (v === 'true' || v === '1' || v === 'yes' || v === 'on')
166
+ return true;
167
+ if (v === 'false' || v === '0' || v === 'no' || v === 'off')
168
+ return false;
169
+ return undefined;
170
+ }
171
+ function parseSkillFile(raw) {
172
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
173
+ if (!m)
174
+ return null;
175
+ const fields = new Map();
176
+ for (const line of m[1].split(/\r?\n/)) {
177
+ const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim());
178
+ if (!kv)
179
+ continue;
180
+ fields.set(kv[1], kv[2].trim().replace(/^['"]|['"]$/g, ''));
181
+ }
182
+ if (fields.has('userInvocable') || fields.has('modelInvocable'))
183
+ return null;
184
+ const name = fields.get('name');
185
+ const description = fields.get('description');
186
+ if (!name || description === undefined || description === '')
187
+ return null;
188
+ const fm = { name, description };
189
+ const wtu = fields.get('whenToUse') ?? fields.get('when-to-use');
190
+ if (wtu)
191
+ fm.whenToUse = wtu;
192
+ const ui = fields.get('user-invocable');
193
+ if (ui !== undefined) {
194
+ const b = parseFrontmatterBoolean(ui);
195
+ if (b === undefined)
196
+ return null;
197
+ fm.userInvocable = b;
198
+ }
199
+ const dmi = fields.get('disable-model-invocation');
200
+ if (dmi !== undefined) {
201
+ const b = parseFrontmatterBoolean(dmi);
202
+ if (b === undefined)
203
+ return null;
204
+ fm.disableModelInvocation = b;
205
+ }
206
+ const content = raw.slice(m[0].length).replace(/^\r?\n+/, '');
207
+ return { fm, content };
208
+ }
209
+ async function scanUserSkills() {
210
+ let dir;
211
+ let sep;
212
+ try {
213
+ const p = await ensurePaths();
214
+ sep = p.home.indexOf('\\') >= 0 ? '\\' : '/';
215
+ dir = userSkillsDir(p.home, sep);
216
+ }
217
+ catch (e) {
218
+ return [];
219
+ }
220
+ let entries = [];
221
+ try {
222
+ entries = await fs.listDir(await fs.resolve(dir));
223
+ }
224
+ catch (e) {
225
+ return [];
226
+ }
227
+ const rows = [];
228
+ const seen = new Set();
229
+ for (const ent of entries) {
230
+ const nm = String(ent.name || '');
231
+ if (!nm || nm === '.system')
232
+ continue;
233
+ // Official parity (dsh-skill-filesystem discoverRoot): a directory entry
234
+ // points at `<dir>/SKILL.md`; a flat `*.md` file in the root is itself a
235
+ // skill. We cannot stat entry types through the fs service, so try the
236
+ // directory form first and fall back to the flat form for `.md` names.
237
+ let path = dir + sep + nm + sep + 'SKILL.md';
238
+ let directory = dir + sep + nm;
239
+ let raw = '';
240
+ try {
241
+ raw = await fs.readText(await fs.resolve(path));
242
+ }
243
+ catch (e) {
244
+ if (!nm.endsWith('.md'))
245
+ continue;
246
+ path = dir + sep + nm;
247
+ directory = dir;
248
+ try {
249
+ raw = await fs.readText(await fs.resolve(path));
250
+ }
251
+ catch (e2) {
252
+ continue;
253
+ }
254
+ }
255
+ const parsed = parseSkillFile(raw);
256
+ if (!parsed) {
257
+ console.warn(`[dsh-mcp-manager] user skill ${path} ignored: missing/invalid frontmatter (name+description required, kebab-case name, official boolean grammar)`);
258
+ continue;
259
+ }
260
+ const { fm, content } = parsed;
261
+ if (!SKILL_NAME_RE.test(fm.name))
262
+ continue;
263
+ if (seen.has(fm.name))
264
+ continue;
265
+ seen.add(fm.name);
266
+ rows.push({
267
+ name: fm.name,
268
+ description: fm.description,
269
+ ...(fm.whenToUse !== undefined ? { whenToUse: fm.whenToUse } : {}),
270
+ invocation: { modelInvocable: fm.disableModelInvocation !== true, userInvocable: fm.userInvocable !== false },
271
+ source: 'user-dsh',
272
+ provider: 'filesystem',
273
+ resourceBase: { kind: 'directory', path: directory },
274
+ path,
275
+ content,
276
+ });
277
+ }
278
+ return rows;
279
+ }
280
+ async function findUserSkill(name) {
281
+ return (await scanUserSkills()).find((s) => s.name === name);
282
+ }
129
283
  async function skillList() {
130
284
  await ensureRestored();
131
285
  const snap = await ctx.skills.snapshot({});
132
- return { ok: true, skills: snap.skills, complete: snap.complete !== false };
286
+ const seen = new Set(snap.skills.map((s) => s.name));
287
+ const extra = (await scanUserSkills()).filter((s) => !seen.has(s.name));
288
+ const skills = snap.skills.concat(extra).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
289
+ return { ok: true, skills, complete: snap.complete !== false };
290
+ }
291
+ // Toggle a USER-LEVEL filesystem skill by editing its SKILL.md frontmatter.
292
+ // This is the only mechanism that works: the rank-0 override provider lives
293
+ // in the global layer, but ctx.skills reads merge the viewing scope's chain
294
+ // and the scoped layer's filesystem entry WINS the duplicate name outright
295
+ // (collectFresh does `merged.set(name, entry)` per layer, last wins) — so an
296
+ // override can never hide a user skill from the / menu. Editing the source
297
+ // file makes the real dsh-skill-filesystem provider (which hot-reloads via
298
+ // its watcher) return the disabled invocation on every scope.
299
+ async function editUserSkillInvocation(name, enabled) {
300
+ const def = await findUserSkill(name);
301
+ if (!def || !def.path)
302
+ return { ok: false, error: '技能不存在: ' + name };
303
+ const raw = await fs.readText(await fs.resolve(def.path)).catch(() => '');
304
+ if (!raw)
305
+ return { ok: false, error: '无法读取技能文件: ' + def.path };
306
+ // Strip any existing invocation flags from the frontmatter block, then
307
+ // re-append the ones matching the requested state. Everything else —
308
+ // body content and other frontmatter keys — is preserved verbatim.
309
+ const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
310
+ if (!fmMatch)
311
+ return { ok: false, error: '技能文件缺少 frontmatter: ' + def.path };
312
+ const fmLines = fmMatch[1].split(/\r?\n/);
313
+ const rest = raw.slice(fmMatch[0].length);
314
+ const stripped = fmLines.filter((line) => !/^(user-invocable|disable-model-invocation):/i.test(line.trim()));
315
+ if (!enabled) {
316
+ stripped.push('user-invocable: false');
317
+ stripped.push('disable-model-invocation: true');
318
+ }
319
+ const next = '---\n' + stripped.join('\n') + '\n---' + rest;
320
+ await fs.writeText(await fs.resolve(def.path), next);
321
+ return { ok: true };
133
322
  }
134
323
  async function skillToggle(args) {
135
324
  const name = String(args.name || '').trim();
@@ -140,6 +329,18 @@ export default {
140
329
  await ensureRestored();
141
330
  const enabled = args.enabled !== false;
142
331
  return withWriteLock(async () => {
332
+ // User-level skills bypass the override provider entirely — editing the
333
+ // SKILL.md frontmatter is what makes the real provider (and thus the
334
+ // / menu) see the change.
335
+ const userDef = await findUserSkill(name);
336
+ if (userDef) {
337
+ // Also drop a stale override row if one was created pre-fix.
338
+ if (overrideSkills.delete(name)) {
339
+ await saveState([...overrideSkills.keys()]);
340
+ skillProviderControl?.invalidate();
341
+ }
342
+ return editUserSkillInvocation(name, enabled);
343
+ }
143
344
  if (enabled) {
144
345
  if (overrideSkills.delete(name)) {
145
346
  await saveState([...overrideSkills.keys()]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xxxyz/dsh-mcp-manager",
3
- "version": "2.1.5",
3
+ "version": "2.2.1",
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",