@gyp8735abc/dsh-mcp-manager 0.0.0-stage → 0.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.
@@ -0,0 +1,87 @@
1
+ // dsh-mcp-manager — per-workspace skill filtering (0.2.x host half).
2
+ //
3
+ // The original plugin got this by patching three sites in the core package
4
+ // `dsh-tool-skill`. Those packages now live inside the application's sealed
5
+ // `app.asar`, so the same behaviour is installed from the plugin instead, by
6
+ // patching the `SkillRegistry` prototype that `dsh-skill` serves:
7
+ //
8
+ // * `snapshot()`/`list()` feed the session's skill catalog message
9
+ // (dsh-tool-skill's `agent/pre-step` listener) and the `skill` tool's
10
+ // lookup, so a disabled skill disappears from both the model's catalog and
11
+ // its own tool lookups;
12
+ // * `get()` feeds the `/name` user invocation and the tool's content read, so
13
+ // a disabled skill can neither be injected nor loaded;
14
+ // * `tools.guard()` runs before the `skill` tool's `execute`, so a direct call
15
+ // is refused with a message that says *why* rather than the tool's generic
16
+ // "unknown or no longer available".
17
+ //
18
+ // Why the prototype and not the service object: `ctx.skills` is a per-context
19
+ // facade that resolves each method from the prototype on every access (two
20
+ // reads of `ctx.skills.snapshot` are not the same function). Assigning to the
21
+ // service instance is therefore never observed by callers — measured, not
22
+ // assumed — while a prototype patch is what that facade picks up.
23
+ //
24
+ // The filter is keyed by the lookup's `cwd`: DSH passes the owning session's
25
+ // `header.cwd` on every skill lookup, which is exactly the workspace this
26
+ // plugin's state is keyed by. A lookup without a `cwd`, or one matching no
27
+ // configured workspace, is never filtered.
28
+
29
+ /**
30
+ * Install the per-workspace skill filter.
31
+ * @param manager - the McpManager instance (supplies `isSkillDisabled`).
32
+ * @param context - the context to install from; pass the `skills`-injected fiber
33
+ * context so the service is guaranteed to exist (defaults to the manager's).
34
+ * @returns a disposer that restores the prototype methods and unregisters the guard.
35
+ */
36
+ export function installSkillFilter(manager, context = manager.ctx) {
37
+ const ctx = context;
38
+ const skills = ctx.get?.("skills", false) ?? ctx.skills ?? null;
39
+ if (skills === null || typeof skills !== "object") {
40
+ ctx.logger?.warn("dsh-mcp-manager: skills service unavailable; per-workspace skill filtering is off");
41
+ return () => {};
42
+ }
43
+ const registry = Object.getPrototypeOf(skills);
44
+ const originalSnapshot = registry?.snapshot;
45
+ const originalGet = registry?.get;
46
+ if (typeof originalSnapshot !== "function" || typeof originalGet !== "function") {
47
+ ctx.logger?.warn("dsh-mcp-manager: unexpected skills service shape; per-workspace skill filtering is off");
48
+ return () => {};
49
+ }
50
+
51
+ // `list()` delegates to `this.snapshot`, so patching `snapshot` covers both.
52
+ registry.snapshot = async function snapshot(options = {}) {
53
+ const snapshotValue = await originalSnapshot.call(this, options);
54
+ if (snapshotValue === null || typeof snapshotValue !== "object" || !Array.isArray(snapshotValue.skills)) {
55
+ return snapshotValue;
56
+ }
57
+ const cwd = options?.cwd;
58
+ const kept = snapshotValue.skills.filter((skill) => manager.isSkillDisabled(cwd, skill?.name) !== true);
59
+ if (kept.length === snapshotValue.skills.length) return snapshotValue;
60
+ return { ...snapshotValue, skills: kept };
61
+ };
62
+ registry.get = async function get(name, options = {}) {
63
+ if (manager.isSkillDisabled(options?.cwd, name) === true) return undefined;
64
+ return originalGet.call(this, name, options);
65
+ };
66
+
67
+ let offGuard = null;
68
+ const tools = ctx.get?.("tools", false) ?? ctx.tools ?? null;
69
+ if (tools !== null && typeof tools.guard === "function") {
70
+ offGuard = tools.guard((execution) => {
71
+ if (execution?.name !== "skill") return undefined;
72
+ const name = execution.arguments?.name;
73
+ if (typeof name !== "string" || name.length === 0) return undefined;
74
+ const cwd = execution.agent?.session?.header?.cwd;
75
+ if (manager.isSkillDisabled(cwd, name) !== true) return undefined;
76
+ return `skill "${name}" is disabled for this workspace`;
77
+ });
78
+ } else {
79
+ ctx.logger?.warn("dsh-mcp-manager: tools.guard unavailable; a disabled skill is refused by lookup only");
80
+ }
81
+
82
+ return () => {
83
+ registry.snapshot = originalSnapshot;
84
+ registry.get = originalGet;
85
+ if (typeof offGuard === "function") offGuard();
86
+ };
87
+ }
@@ -0,0 +1,518 @@
1
+ // dsh-mcp-manager — Claude-Code skill roots (host-half helper).
2
+ //
3
+ // An older build of this plugin patched the core package
4
+ // `@deepseek-ai/dsh-skill-filesystem` to add two roots and a fallback:
5
+ // 1. a project root `<projectRoot>/.claude/skills`,
6
+ // 2. a user root `~/.claude/skills`,
7
+ // 3. discovery of a Claude-Code-style `SKILL.md` WITHOUT YAML frontmatter.
8
+ // 0.2.0-rc.2 seals that package inside app.asar, so the patch cannot be
9
+ // re-applied. This module re-implements the three behaviours on the public
10
+ // seam the shipped provider itself uses: `ctx.skills.registerProvider(create)`
11
+ // (`@deepseek-ai/dsh-skill`, the skill-registry Service; the shipped
12
+ // `dsh-skill-filesystem` is just another provider on it).
13
+ //
14
+ // Precedence, as the shipped code actually expresses it
15
+ // (`dsh-skill/lib/index.js`): every candidate carries a numeric `rank`, and
16
+ // `compareIndexedCandidates` sorts `rank asc || providerOrder || localOrder`
17
+ // inside ONE scope layer, so the LOWEST rank wins a same-named skill. The
18
+ // shipped provider ranks project-dsh 100, project-agents 200, custom 300,
19
+ // user-dsh 400, user-agents 500 and bundled 600
20
+ // (`dsh-skill-filesystem/lib/index.js:21-25,150-188`). The legacy ranks map:
21
+ // project `.claude/skills` 150 -> 250 (still below custom 300 and every
22
+ // user root; it loses to project-dsh 100 and project-agents 200)
23
+ // user `~/.claude/skills` 450 -> 450 (unchanged: between user-dsh 400
24
+ // and user-agents 500)
25
+ // Rank 250 keeps the legacy intent under the shipped ascending-rank rule; the
26
+ // single difference from the patched ranks is that a `.claude` skill now also
27
+ // loses to a same-named project `.agents/skills` skill, which is what the
28
+ // legacy patch's own rank slot (between project-dsh and project-agents) was
29
+ // reaching for under a higher-rank-wins reading.
30
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
31
+ import { basename, dirname, join, resolve, sep } from "node:path";
32
+ import { homedir } from "node:os";
33
+
34
+ /** Provider name on `ctx.skills`; unique inside one scope layer. */
35
+ export const PROVIDER_NAME = "claude-roots";
36
+
37
+ /** Root sources, mirroring the shipped naming scheme. */
38
+ export const SOURCE_PROJECT_CLAUDE = "project-claude";
39
+ export const SOURCE_USER_CLAUDE = "user-claude";
40
+
41
+ /** Project `.claude/skills`: below project-dsh (100) and project-agents (200). */
42
+ const PROJECT_CLAUDE_RANK = 250;
43
+ /** User `~/.claude/skills`: between user-dsh (400) and user-agents (500). */
44
+ const USER_CLAUDE_RANK = 450;
45
+ /** Legacy fallback truncation for a description taken from the body. */
46
+ const MAX_DESCRIPTION_LENGTH = 300;
47
+ /** Legacy cap for a fallback name taken from the file system identity. */
48
+ const MAX_NAME_LENGTH = 64;
49
+ // The registry validates every candidate name against the shipped kebab-case
50
+ // grammar (`dsh-skill/lib/index.js:17,454`) from inside its own collect pass,
51
+ // where a violation is a THROWN error that would break the whole catalog, so a
52
+ // candidate whose identity cannot be expressed in that grammar is skipped
53
+ // instead of published.
54
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
55
+
56
+ /**
57
+ * `.claude/skills` provider: two roots, the shipped frontmatter parser
58
+ * semantics, and the Claude-Code frontmatter-less fallback.
59
+ */
60
+ class ClaudeSkillRootProvider {
61
+ constructor(ctx, control) {
62
+ this.ctx = ctx;
63
+ this.name = PROVIDER_NAME;
64
+ this.control = control;
65
+ this.disposed = false;
66
+ this.seenRoots = [];
67
+ this.reportedParser = false;
68
+ this.warn = this.warn.bind(this);
69
+ control.signal.addEventListener(
70
+ "abort",
71
+ () => {
72
+ this.disposed = true;
73
+ },
74
+ { once: true }
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Discover candidates for one lookup. An absent root is skipped silently; a
80
+ * root that fails for another reason makes the observation incomplete (which
81
+ * the registry never caches) instead of failing the whole catalog.
82
+ */
83
+ async list(options) {
84
+ if (this.disposed) return [];
85
+ const parseYaml = await loadFrontmatterParser();
86
+ this.reportParser(parseYaml);
87
+ const roots = this.roots(options?.cwd);
88
+ this.seenRoots = roots;
89
+ const candidates = [];
90
+ let complete = true;
91
+ for (const root of roots) {
92
+ try {
93
+ for (const candidate of discoverRoot(root, parseYaml, this.warn)) candidates.push(candidate);
94
+ } catch (error) {
95
+ complete = false;
96
+ this.warn(`dsh-mcp-manager: skill root ${root.path} skipped: ${String(error)}`);
97
+ }
98
+ }
99
+ return complete ? candidates : { candidates, complete };
100
+ }
101
+
102
+ /** Load the winning candidate's body from its locator. */
103
+ async get(candidate, options) {
104
+ if (this.disposed || options?.signal?.aborted === true) return undefined;
105
+ const locator = candidate?.locator;
106
+ if (locator === undefined || typeof locator.path !== "string") return undefined;
107
+ const parseYaml = await loadFrontmatterParser();
108
+ const parsed = parseSkillFile(locator.path, parseYaml, this.warn);
109
+ if (parsed === undefined) return undefined;
110
+ return {
111
+ name: parsed.name,
112
+ description: parsed.description,
113
+ ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
114
+ invocation: parsed.invocation,
115
+ source: candidate.source,
116
+ provider: this.name,
117
+ resourceBase: { kind: "directory", path: locator.directory },
118
+ path: parsed.path,
119
+ ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}),
120
+ content: parsed.content,
121
+ };
122
+ }
123
+
124
+ /** The roots this provider owns for one lookup. */
125
+ roots(cwd) {
126
+ const roots = [];
127
+ if (typeof cwd === "string" && cwd.length > 0) {
128
+ roots.push({
129
+ path: join(findProjectRoot(resolve(cwd)), ".claude", "skills"),
130
+ source: SOURCE_PROJECT_CLAUDE,
131
+ rank: PROJECT_CLAUDE_RANK,
132
+ });
133
+ }
134
+ roots.push({
135
+ path: join(homedir(), ".claude", "skills"),
136
+ source: SOURCE_USER_CLAUDE,
137
+ rank: USER_CLAUDE_RANK,
138
+ });
139
+ return roots;
140
+ }
141
+
142
+ /** Whether one host path can change what this provider just discovered. */
143
+ observes(path) {
144
+ const target = resolve(path).toLowerCase();
145
+ for (const root of this.seenRoots) {
146
+ const lower = resolve(root.path).toLowerCase();
147
+ if (target === lower || target.startsWith(lower + sep)) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ /** Drop the registry's cached catalog for this provider. */
153
+ invalidate() {
154
+ if (this.disposed) return;
155
+ this.control.invalidate();
156
+ }
157
+
158
+ /** No watchers are owned, so disposal only contains late callbacks. */
159
+ dispose() {
160
+ this.disposed = true;
161
+ }
162
+
163
+ warn(message) {
164
+ this.ctx.logger?.warn(message);
165
+ }
166
+
167
+ /** Record once which frontmatter parser this runtime can reach. */
168
+ reportParser(parseYaml) {
169
+ if (this.reportedParser) return;
170
+ this.reportedParser = true;
171
+ const kind = parseYaml === parseFlatFrontmatter ? "built-in flat reader" : "shipped yaml parser";
172
+ this.ctx.logger?.info(`dsh-mcp-manager: .claude skill roots use the ${kind}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Register the `.claude/skills` provider on a context that has `skills`.
178
+ * @param ctx - context carrying the injected `skills` service.
179
+ */
180
+ export function registerClaudeSkillRoots(ctx) {
181
+ let provider;
182
+ ctx.effect(function* () {
183
+ yield ctx.skills.registerProvider((control) => {
184
+ provider = new ClaudeSkillRootProvider(ctx, control);
185
+ return provider;
186
+ });
187
+ // No host watcher (the shipped provider's chokidar manager is package
188
+ // private): invalidate on first-party filesystem mutations under our
189
+ // roots, the same signal `dsh-skill-filesystem` reacts to.
190
+ ctx.on("fs/observed", (target, _observation, actor) => {
191
+ if (!isMutationActor(actor)) return;
192
+ const path = target?.displayPath;
193
+ if (typeof path !== "string") return;
194
+ if (provider?.observes(path) === true) provider.invalidate();
195
+ });
196
+ yield () => provider?.dispose();
197
+ }, "dsh-mcp-manager: .claude skill roots");
198
+ }
199
+
200
+ /** Discover one root: `<name>/SKILL.md` bundles plus loose `*.md` files. */
201
+ function discoverRoot(root, parseYaml, warn) {
202
+ const candidates = [];
203
+ let entries;
204
+ try {
205
+ entries = readdirSync(root.path, { withFileTypes: true, encoding: "utf8" });
206
+ } catch (error) {
207
+ if (isAbsentPathError(error)) return candidates; // absent root: skipped silently
208
+ throw error;
209
+ }
210
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
211
+ const fullPath = join(root.path, entry.name);
212
+ const kind = entryKind(entry, fullPath);
213
+ const locator =
214
+ kind === "directory"
215
+ ? { path: join(fullPath, "SKILL.md"), directory: fullPath }
216
+ : kind === "file" && entry.name.endsWith(".md")
217
+ ? { path: fullPath, directory: root.path }
218
+ : undefined;
219
+ if (locator === undefined) continue;
220
+ const parsed = parseSkillFile(locator.path, parseYaml, warn);
221
+ if (parsed === undefined) continue;
222
+ candidates.push({
223
+ name: parsed.name,
224
+ description: parsed.description,
225
+ ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
226
+ invocation: parsed.invocation,
227
+ source: root.source,
228
+ provider: PROVIDER_NAME,
229
+ rank: root.rank,
230
+ locator,
231
+ resourceBase: { kind: "directory", path: locator.directory },
232
+ path: parsed.path,
233
+ ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}),
234
+ });
235
+ }
236
+ return candidates;
237
+ }
238
+
239
+ /**
240
+ * Parse one skill file exactly as `dsh-skill-filesystem` does, with the
241
+ * Claude-Code fallback: a file without YAML frontmatter still yields a skill
242
+ * (name from the on-disk identity, description from the first content line).
243
+ * Returns `undefined` for a malformed or unusable file; never throws for
244
+ * content problems.
245
+ */
246
+ function parseSkillFile(filePath, parseYaml, warn) {
247
+ let raw;
248
+ try {
249
+ raw = readFileSync(filePath, "utf8");
250
+ } catch (error) {
251
+ if (isAbsentPathError(error)) return undefined;
252
+ throw error;
253
+ }
254
+ let frontmatter;
255
+ try {
256
+ frontmatter = parseFrontmatter(raw, parseYaml);
257
+ } catch (error) {
258
+ warn(`skill file ${filePath} ignored: invalid YAML frontmatter: ${String(error)}`);
259
+ return undefined;
260
+ }
261
+ if (frontmatter === null) {
262
+ warn(`skill file ${filePath} ignored: missing YAML frontmatter`);
263
+ return undefined;
264
+ }
265
+ if (frontmatter !== undefined) {
266
+ const name = stringField(frontmatter.data, "name");
267
+ const description = stringField(frontmatter.data, "description");
268
+ if (name === undefined || description === undefined) {
269
+ warn(`skill file ${filePath} ignored: frontmatter requires name and description`);
270
+ return undefined;
271
+ }
272
+ if (!SKILL_NAME.test(name)) {
273
+ warn(`skill file ${filePath} ignored: invalid skill name "${name}"`);
274
+ return undefined;
275
+ }
276
+ let invocation;
277
+ try {
278
+ invocation = parseInvocationPolicy(frontmatter.data);
279
+ } catch (error) {
280
+ warn(`skill file ${filePath} ignored: invalid invocation frontmatter: ${String(error)}`);
281
+ return undefined;
282
+ }
283
+ return {
284
+ name,
285
+ description,
286
+ ...optionalString(frontmatter.data, "whenToUse"),
287
+ invocation,
288
+ ...optionalMetadata(frontmatter.data),
289
+ path: realPath(filePath),
290
+ content: frontmatter.body.trim(),
291
+ };
292
+ }
293
+ // Claude-Code compatibility: no YAML frontmatter at all.
294
+ const name = fallbackName(filePath);
295
+ const description = fallbackDescription(raw);
296
+ if (name === undefined || description === undefined) {
297
+ warn(`skill file ${filePath} ignored: no frontmatter and no usable body line`);
298
+ return undefined;
299
+ }
300
+ return {
301
+ name,
302
+ description,
303
+ invocation: { modelInvocable: true, userInvocable: true },
304
+ path: realPath(filePath),
305
+ content: raw.trim(),
306
+ };
307
+ }
308
+
309
+ /**
310
+ * The shipped frontmatter splitter (`dsh-skill-filesystem/lib/index.js:780`),
311
+ * mirrored: `---` on the first line, then the next `---` line, YAML between.
312
+ * The shipped function is module private and the package exports no parser
313
+ * helper (its export list is `Config, FileSystemSkillProvider, apply, inject,
314
+ * name`), so only the algorithm is shared; the YAML parse itself reuses the
315
+ * very dependency that package imports (`yaml`, line 7).
316
+ *
317
+ * @returns `undefined` when the file carries no frontmatter (Claude-Code
318
+ * style, eligible for the fallback), `null` when it carries frontmatter that
319
+ * is not a YAML mapping, and the split pair otherwise.
320
+ */
321
+ function parseFrontmatter(raw, parseYaml) {
322
+ const firstLineEnd = raw.indexOf("\n");
323
+ const firstLine = (firstLineEnd < 0 ? raw : raw.slice(0, firstLineEnd)).replace(/\r$/, "");
324
+ // The fallback is only for files that never opened a frontmatter block: a
325
+ // file whose first line is `---` is frontmatter-shaped, so a broken block is
326
+ // a malformed skill rather than a Claude-Code-style body.
327
+ if (firstLine !== "---") return undefined;
328
+ const start = firstLineEnd + 1;
329
+ const closing = findClosingFrontmatter(raw, start);
330
+ if (closing === undefined) return null;
331
+ const parsed = parseYaml(raw.slice(start, closing.start));
332
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
333
+ return { data: parsed, body: raw.slice(closing.bodyStart) };
334
+ }
335
+
336
+ function findClosingFrontmatter(raw, start) {
337
+ let lineStart = start;
338
+ while (lineStart <= raw.length) {
339
+ const nextNewline = raw.indexOf("\n", lineStart);
340
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
341
+ if (raw.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") {
342
+ return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
343
+ }
344
+ if (nextNewline < 0) return undefined;
345
+ lineStart = nextNewline + 1;
346
+ }
347
+ return undefined;
348
+ }
349
+
350
+ /**
351
+ * Resolve the YAML parser once per process. The shipped package imports
352
+ * `parse` from "yaml"; when that specifier is not resolvable from here the
353
+ * flat reader below covers the documented name/description frontmatter.
354
+ */
355
+ let frontmatterParserPromise;
356
+ function loadFrontmatterParser() {
357
+ frontmatterParserPromise ??= import("yaml").then(
358
+ (module) => (typeof module.parse === "function" ? module.parse : parseFlatFrontmatter),
359
+ () => parseFlatFrontmatter
360
+ );
361
+ return frontmatterParserPromise;
362
+ }
363
+
364
+ /** Minimal `key: value` reader (plus folded/literal blocks) used as a fallback. */
365
+ function parseFlatFrontmatter(text) {
366
+ const data = {};
367
+ let block;
368
+ let sawKey = false;
369
+ for (const line of text.split(/\r?\n/)) {
370
+ if (block !== undefined) {
371
+ if (/^\s+\S/.test(line)) {
372
+ block.lines.push(line.trim());
373
+ continue;
374
+ }
375
+ data[block.key] = block.lines.join(" ").trim();
376
+ block = undefined;
377
+ }
378
+ const match = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
379
+ if (match === null) continue;
380
+ sawKey = true;
381
+ const value = match[2].trim();
382
+ if (/^[>|][-+]?$/.test(value)) {
383
+ block = { key: match[1], lines: [] };
384
+ continue;
385
+ }
386
+ data[match[1]] = unquoteScalar(value);
387
+ }
388
+ if (block !== undefined) data[block.key] = block.lines.join(" ").trim();
389
+ return sawKey ? data : undefined;
390
+ }
391
+
392
+ function unquoteScalar(value) {
393
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
394
+ return value.slice(1, -1);
395
+ }
396
+ return value;
397
+ }
398
+
399
+ /** Legacy fallback identity: parent directory of `SKILL.md`, else the file stem. */
400
+ function fallbackName(filePath) {
401
+ const base = basename(filePath);
402
+ const stem = base.toLowerCase() === "skill.md" ? basename(dirname(filePath)) : base.replace(/\.md$/i, "");
403
+ const sanitized = stem.replace(/[^A-Za-z0-9_-]/g, "-");
404
+ const name = sanitized
405
+ .toLowerCase()
406
+ .replace(/_/g, "-")
407
+ .replace(/-{2,}/g, "-")
408
+ .replace(/^-+|-+$/g, "");
409
+ if (name.length === 0 || name.length > MAX_NAME_LENGTH) return undefined;
410
+ return SKILL_NAME.test(name) ? name : undefined;
411
+ }
412
+
413
+ /** Legacy fallback description: first non-empty line, leading `#` stripped. */
414
+ function fallbackDescription(raw) {
415
+ const line = raw
416
+ .split(/\r?\n/)
417
+ .map((text) => text.replace(/^#+\s*/, "").trim())
418
+ .find((text) => text.length > 0);
419
+ if (line === undefined) return undefined;
420
+ return line.slice(0, MAX_DESCRIPTION_LENGTH);
421
+ }
422
+
423
+ function entryKind(entry, fullPath) {
424
+ if (entry.isDirectory()) return "directory";
425
+ if (entry.isFile()) return "file";
426
+ if (!entry.isSymbolicLink()) return undefined;
427
+ try {
428
+ const info = statSync(fullPath);
429
+ if (info.isDirectory()) return "directory";
430
+ if (info.isFile()) return "file";
431
+ } catch {
432
+ return undefined;
433
+ }
434
+ return undefined;
435
+ }
436
+
437
+ /** The shipped project-root rule: the nearest ancestor holding `.git`, else cwd. */
438
+ function findProjectRoot(cwd) {
439
+ let current = cwd;
440
+ while (true) {
441
+ if (existsSync(join(current, ".git"))) return current;
442
+ const parent = dirname(current);
443
+ if (parent === current) return cwd;
444
+ current = parent;
445
+ }
446
+ }
447
+
448
+ function realPath(filePath) {
449
+ try {
450
+ return realpathSync(filePath);
451
+ } catch {
452
+ return filePath;
453
+ }
454
+ }
455
+
456
+ function stringField(data, key) {
457
+ const value = data[key];
458
+ return typeof value === "string" && value.length > 0 ? value : undefined;
459
+ }
460
+
461
+ function optionalString(data, key) {
462
+ const value = data[key];
463
+ return typeof value === "string" && value.length > 0 ? { [key]: value } : {};
464
+ }
465
+
466
+ function optionalMetadata(data) {
467
+ const value = data.metadata;
468
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return { metadata: value };
469
+ return {};
470
+ }
471
+
472
+ /** Shipped invocation policy (`dsh-skill-filesystem/lib/index.js:849`). */
473
+ function parseInvocationPolicy(data) {
474
+ rejectLegacyInvocationKey(data, "disableModelInvocation", "disable-model-invocation");
475
+ rejectLegacyInvocationKey(data, "modelInvocable", "disable-model-invocation");
476
+ rejectLegacyInvocationKey(data, "userInvocable", "user-invocable");
477
+ const disableModelInvocation = frontmatterBoolean(data, "disable-model-invocation");
478
+ const userInvocable = frontmatterBoolean(data, "user-invocable");
479
+ return {
480
+ modelInvocable: disableModelInvocation !== true,
481
+ userInvocable: userInvocable !== false,
482
+ };
483
+ }
484
+
485
+ function rejectLegacyInvocationKey(data, legacy, canonical) {
486
+ if (Object.hasOwn(data, legacy)) throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`);
487
+ }
488
+
489
+ function frontmatterBoolean(data, key) {
490
+ if (!Object.hasOwn(data, key)) return undefined;
491
+ const value = data[key];
492
+ if (typeof value === "boolean") return value;
493
+ if (value === 1 || value === "1") return true;
494
+ if (value === 0 || value === "0") return false;
495
+ if (typeof value === "string") {
496
+ switch (value.toLowerCase()) {
497
+ case "true":
498
+ case "yes":
499
+ case "on":
500
+ return true;
501
+ case "false":
502
+ case "no":
503
+ case "off":
504
+ return false;
505
+ }
506
+ }
507
+ throw new TypeError(`frontmatter field "${key}" must be a boolean`);
508
+ }
509
+
510
+ /** Shipped `fs/observed` actor filter: only model-facing writes and edits. */
511
+ function isMutationActor(actor) {
512
+ if (actor === undefined || actor === null || !("name" in actor)) return false;
513
+ return actor.name === "edit" || actor.name === "write";
514
+ }
515
+
516
+ function isAbsentPathError(error) {
517
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR";
518
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "MCP manager (per workspace)",
4
+ "description": "Enable or disable global MCP, project MCP and skills separately for each workspace, from one popup on the workspace row"
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "MCP 管理器(按工作区)",
4
+ "description": "在每个工作区行用一个弹窗,按工作区独立启用/停用全局 MCP、项目 MCP 与技能"
5
+ }
6
+ }