@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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +278 -2
- package/README.zh.md +263 -0
- package/client/client.js +535 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +11 -0
- package/lib/index.js +1099 -0
- package/lib/skill-filter.js +87 -0
- package/lib/skill-roots.js +518 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
|
@@ -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