dsh-skills-manager 0.1.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 +84 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1317 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +700 -0
- package/lib/types/client/index.d.ts +3 -0
- package/lib/types/client/index.js +382 -0
- package/lib/types/index.d.ts +37 -0
- package/lib/types/index.js +586 -0
- package/lib/types/state.d.ts +7 -0
- package/lib/types/state.js +108 -0
- package/lib/types/types.d.ts +33 -0
- package/lib/types/types.js +1 -0
- package/package.json +96 -0
- package/plugin-design.md +22 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
8
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
9
|
+
import { FileSystemSkillProvider } from "@deepseek-ai/dsh-skill-filesystem";
|
|
10
|
+
import { parse } from "yaml";
|
|
11
|
+
|
|
12
|
+
//#region lib/types/state.js
|
|
13
|
+
const EMPTY_STATE = {
|
|
14
|
+
version: 1,
|
|
15
|
+
skills: {},
|
|
16
|
+
globalEnabled: [],
|
|
17
|
+
projectEnabled: {}
|
|
18
|
+
};
|
|
19
|
+
function normalizeState(value) {
|
|
20
|
+
if (!isRecord$1(value) || value.version !== 1 || !isRecord$1(value.skills) || !Array.isArray(value.globalEnabled) || !isRecord$1(value.projectEnabled)) throw new Error("skill-manager state.json has an unsupported shape");
|
|
21
|
+
const skills = {};
|
|
22
|
+
for (const [name$1, entry] of Object.entries(value.skills)) {
|
|
23
|
+
if (!isRecord$1(entry) || entry.name !== name$1 || typeof entry.description !== "string" || entry.layout !== "directory" && entry.layout !== "file" || typeof entry.installedAt !== "string") throw new Error(`skill-manager state.json has an invalid skill entry: ${name$1}`);
|
|
24
|
+
skills[name$1] = {
|
|
25
|
+
name: name$1,
|
|
26
|
+
description: entry.description,
|
|
27
|
+
layout: entry.layout,
|
|
28
|
+
installedAt: entry.installedAt,
|
|
29
|
+
...typeof entry.updatedAt === "string" ? { updatedAt: entry.updatedAt } : {},
|
|
30
|
+
...entry.sourceType === "local" || entry.sourceType === "git" ? { sourceType: entry.sourceType } : {},
|
|
31
|
+
...typeof entry.sourcePath === "string" && isAbsolute(entry.sourcePath) ? { sourcePath: entry.sourcePath } : {},
|
|
32
|
+
...typeof entry.sourceUrl === "string" ? { sourceUrl: entry.sourceUrl } : {},
|
|
33
|
+
...typeof entry.sourceSubpath === "string" ? { sourceSubpath: entry.sourceSubpath } : {},
|
|
34
|
+
...typeof entry.contentHash === "string" ? { contentHash: entry.contentHash } : {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
version: 1,
|
|
39
|
+
skills,
|
|
40
|
+
globalEnabled: cleanNames(value.globalEnabled, skills),
|
|
41
|
+
projectEnabled: cleanProjects(value.projectEnabled, skills)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function setEnabled(state, name$1, scope, projectRoot, enabled) {
|
|
45
|
+
if (!(name$1 in state.skills)) throw new Error(`skill is not installed: ${name$1}`);
|
|
46
|
+
if (scope === "project" && !projectRoot) throw new Error("projectRoot is required for project scope");
|
|
47
|
+
if (scope === "global") return {
|
|
48
|
+
...state,
|
|
49
|
+
globalEnabled: updateNames(state.globalEnabled, name$1, enabled)
|
|
50
|
+
};
|
|
51
|
+
const next = updateNames(state.projectEnabled[projectRoot] ?? [], name$1, enabled);
|
|
52
|
+
const projectEnabled = { ...state.projectEnabled };
|
|
53
|
+
if (next.length === 0) delete projectEnabled[projectRoot];
|
|
54
|
+
else projectEnabled[projectRoot] = next;
|
|
55
|
+
return {
|
|
56
|
+
...state,
|
|
57
|
+
projectEnabled
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function removeSkill(state, name$1) {
|
|
61
|
+
if (!(name$1 in state.skills)) throw new Error(`skill is not installed: ${name$1}`);
|
|
62
|
+
const skills = { ...state.skills };
|
|
63
|
+
delete skills[name$1];
|
|
64
|
+
return {
|
|
65
|
+
version: 1,
|
|
66
|
+
skills,
|
|
67
|
+
globalEnabled: state.globalEnabled.filter((item) => item !== name$1),
|
|
68
|
+
projectEnabled: Object.fromEntries(Object.entries(state.projectEnabled).map(([root, names]) => [root, names.filter((item) => item !== name$1)]).filter(([, names]) => names.length > 0))
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isPathWithin(root, target) {
|
|
72
|
+
if (!isAbsolute(root) || !isAbsolute(target)) return false;
|
|
73
|
+
const child = relative(root, target);
|
|
74
|
+
return child === "" || child !== ".." && !child.startsWith(`..${sep}`);
|
|
75
|
+
}
|
|
76
|
+
function isEnabledFor(state, name$1, cwd) {
|
|
77
|
+
if (state.globalEnabled.includes(name$1)) return true;
|
|
78
|
+
if (!cwd) return false;
|
|
79
|
+
return Object.entries(state.projectEnabled).some(([root, names]) => names.includes(name$1) && isPathWithin(root, cwd));
|
|
80
|
+
}
|
|
81
|
+
function updateNames(names, name$1, enabled) {
|
|
82
|
+
const next = new Set(names);
|
|
83
|
+
if (enabled) next.add(name$1);
|
|
84
|
+
else next.delete(name$1);
|
|
85
|
+
return [...next].sort();
|
|
86
|
+
}
|
|
87
|
+
function cleanNames(value, skills) {
|
|
88
|
+
return [...new Set(value.filter((item) => typeof item === "string" && item in skills))].sort();
|
|
89
|
+
}
|
|
90
|
+
function cleanProjects(value, skills) {
|
|
91
|
+
const result = {};
|
|
92
|
+
for (const [root, names] of Object.entries(value)) {
|
|
93
|
+
if (!isAbsolute(root) || !Array.isArray(names)) continue;
|
|
94
|
+
const clean = cleanNames(names, skills);
|
|
95
|
+
if (clean.length > 0) result[root] = clean;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function isRecord$1(value) {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region lib/types/index.js
|
|
105
|
+
const name = "dsh-skills-manager";
|
|
106
|
+
const inject = ["skills", "connection"];
|
|
107
|
+
const execFileAsync = promisify(execFile);
|
|
108
|
+
const Config = Schema.object({ storageDir: Schema.string().default("") });
|
|
109
|
+
var SkillManager = class {
|
|
110
|
+
ctx;
|
|
111
|
+
root;
|
|
112
|
+
library;
|
|
113
|
+
stateFile;
|
|
114
|
+
state = EMPTY_STATE;
|
|
115
|
+
loaded;
|
|
116
|
+
writeQueue = Promise.resolve();
|
|
117
|
+
updateChecks = /* @__PURE__ */ new Map();
|
|
118
|
+
providerControl;
|
|
119
|
+
filesystemProvider;
|
|
120
|
+
constructor(ctx, config) {
|
|
121
|
+
this.ctx = ctx;
|
|
122
|
+
const storageDir = config.storageDir?.trim();
|
|
123
|
+
this.root = storageDir ? resolve(storageDir) : join(resolveDshHome(), "skill-manager");
|
|
124
|
+
this.library = join(this.root, "library");
|
|
125
|
+
this.stateFile = join(this.root, "state.json");
|
|
126
|
+
this.loaded = this.load();
|
|
127
|
+
}
|
|
128
|
+
provider(control) {
|
|
129
|
+
this.providerControl = control;
|
|
130
|
+
this.filesystemProvider = new FileSystemSkillProvider(this.ctx, control, {
|
|
131
|
+
providerName: "skill-manager-library",
|
|
132
|
+
includeDefaultRoots: false,
|
|
133
|
+
customSkillDirs: [this.library],
|
|
134
|
+
watch: true
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
name: "skill-manager",
|
|
138
|
+
list: async (options) => {
|
|
139
|
+
await this.loaded;
|
|
140
|
+
const discovered = await this.filesystemProvider.list(options);
|
|
141
|
+
return (Array.isArray(discovered) ? discovered : discovered.candidates).filter((candidate) => isEnabledFor(this.state, candidate.name, options.cwd)).map((candidate) => this.managedCandidate(candidate));
|
|
142
|
+
},
|
|
143
|
+
get: async (candidate, options) => {
|
|
144
|
+
await this.loaded;
|
|
145
|
+
const locator = candidate.locator;
|
|
146
|
+
return this.filesystemProvider.get(locator.candidate, options);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async catalog(projectRoot) {
|
|
151
|
+
await this.loaded;
|
|
152
|
+
return {
|
|
153
|
+
entries: Object.values(this.state.skills).sort((a, b) => a.name.localeCompare(b.name)).map((skill) => {
|
|
154
|
+
const check = this.updateChecks.get(skill.name);
|
|
155
|
+
return {
|
|
156
|
+
...skill,
|
|
157
|
+
globalEnabled: this.state.globalEnabled.includes(skill.name),
|
|
158
|
+
projectEnabled: projectRoot !== void 0 && (this.state.projectEnabled[projectRoot] ?? []).includes(skill.name),
|
|
159
|
+
effectiveEnabled: isEnabledFor(this.state, skill.name, projectRoot),
|
|
160
|
+
updateSupported: Boolean(skill.sourcePath || skill.sourceUrl),
|
|
161
|
+
updateAvailable: check?.available ?? false,
|
|
162
|
+
...check?.error ? { updateError: check.error } : {}
|
|
163
|
+
};
|
|
164
|
+
}),
|
|
165
|
+
...projectRoot ? { projectRoot } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async install(sourcePath, projectRoot) {
|
|
169
|
+
const sources = await discoverSources(sourcePath);
|
|
170
|
+
return this.installSources(sources, (source) => ({
|
|
171
|
+
sourceType: "local",
|
|
172
|
+
sourcePath: source.path
|
|
173
|
+
}), projectRoot);
|
|
174
|
+
}
|
|
175
|
+
async installRemote(input, projectRoot) {
|
|
176
|
+
const sourceUrl = normalizeGitSource(input);
|
|
177
|
+
const checkout = await cloneGitRepository(sourceUrl);
|
|
178
|
+
try {
|
|
179
|
+
const sources = await discoverRepositorySources(checkout);
|
|
180
|
+
return await this.installSources(sources, (source) => ({
|
|
181
|
+
sourceType: "git",
|
|
182
|
+
sourceUrl,
|
|
183
|
+
sourceSubpath: relative(checkout, source.path).split(sep).join("/")
|
|
184
|
+
}), projectRoot);
|
|
185
|
+
} finally {
|
|
186
|
+
await rm(checkout, {
|
|
187
|
+
recursive: true,
|
|
188
|
+
force: true
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async installSources(sources, originFor, projectRoot) {
|
|
193
|
+
await this.loaded;
|
|
194
|
+
const duplicate = sources.find((source) => source.name in this.state.skills);
|
|
195
|
+
if (duplicate) throw new Error(`skill is already installed: ${duplicate.name}`);
|
|
196
|
+
await mkdir(this.library, { recursive: true });
|
|
197
|
+
const installed = {};
|
|
198
|
+
const staged = [];
|
|
199
|
+
const previousState = this.state;
|
|
200
|
+
try {
|
|
201
|
+
for (const source of sources) {
|
|
202
|
+
const target = join(this.library, source.layout === "file" ? `${source.name}.md` : source.name);
|
|
203
|
+
if (await pathExists(target)) throw new Error(`skill target already exists: ${target}`);
|
|
204
|
+
const temp = `${target}.tmp-${randomUUID()}`;
|
|
205
|
+
await cp(source.path, temp, {
|
|
206
|
+
recursive: source.layout === "directory",
|
|
207
|
+
errorOnExist: true,
|
|
208
|
+
force: false,
|
|
209
|
+
verbatimSymlinks: true
|
|
210
|
+
});
|
|
211
|
+
staged.push({
|
|
212
|
+
temp,
|
|
213
|
+
target,
|
|
214
|
+
layout: source.layout,
|
|
215
|
+
committed: false
|
|
216
|
+
});
|
|
217
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
218
|
+
installed[source.name] = {
|
|
219
|
+
name: source.name,
|
|
220
|
+
description: source.description,
|
|
221
|
+
layout: source.layout,
|
|
222
|
+
installedAt: now,
|
|
223
|
+
updatedAt: now,
|
|
224
|
+
...originFor(source),
|
|
225
|
+
contentHash: await hashSkillSource(temp)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
for (const item of staged) {
|
|
229
|
+
await rename(item.temp, item.target);
|
|
230
|
+
item.committed = true;
|
|
231
|
+
}
|
|
232
|
+
this.state = {
|
|
233
|
+
...this.state,
|
|
234
|
+
skills: {
|
|
235
|
+
...this.state.skills,
|
|
236
|
+
...installed
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
await this.persist();
|
|
240
|
+
for (const skillName of Object.keys(installed)) this.updateChecks.set(skillName, { available: false });
|
|
241
|
+
this.providerControl?.invalidate();
|
|
242
|
+
return this.catalog(projectRoot);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this.state = previousState;
|
|
245
|
+
await Promise.all(staged.flatMap((item) => [rm(item.temp, {
|
|
246
|
+
recursive: item.layout === "directory",
|
|
247
|
+
force: true
|
|
248
|
+
}), ...item.committed ? [rm(item.target, {
|
|
249
|
+
recursive: item.layout === "directory",
|
|
250
|
+
force: true
|
|
251
|
+
})] : []]));
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async checkUpdates(projectRoot) {
|
|
256
|
+
await this.loaded;
|
|
257
|
+
const checkouts = [];
|
|
258
|
+
const checkoutCache = /* @__PURE__ */ new Map();
|
|
259
|
+
const checkoutFor = (sourceUrl) => {
|
|
260
|
+
let checkout = checkoutCache.get(sourceUrl);
|
|
261
|
+
if (!checkout) {
|
|
262
|
+
checkout = cloneGitRepository(sourceUrl).then((path) => {
|
|
263
|
+
checkouts.push(path);
|
|
264
|
+
return path;
|
|
265
|
+
});
|
|
266
|
+
checkoutCache.set(sourceUrl, checkout);
|
|
267
|
+
}
|
|
268
|
+
return checkout;
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
for (const skill of Object.values(this.state.skills)) {
|
|
272
|
+
if (!skill.contentHash || !skill.sourcePath && !skill.sourceUrl) {
|
|
273
|
+
this.updateChecks.delete(skill.name);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const source = await resolveTrackedSource(skill, checkoutFor);
|
|
278
|
+
this.updateChecks.set(skill.name, { available: await hashSkillSource(source.path) !== skill.contentHash });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
this.updateChecks.set(skill.name, {
|
|
281
|
+
available: false,
|
|
282
|
+
error: messageOf(error)
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return this.catalog(projectRoot);
|
|
287
|
+
} finally {
|
|
288
|
+
await Promise.all(checkouts.map((path) => rm(path, {
|
|
289
|
+
recursive: true,
|
|
290
|
+
force: true
|
|
291
|
+
})));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async update(name$1, sourcePath, projectRoot) {
|
|
295
|
+
await this.loaded;
|
|
296
|
+
const current = this.state.skills[name$1];
|
|
297
|
+
if (!current) throw new Error(`skill is not installed: ${name$1}`);
|
|
298
|
+
const resolvedSource = sourcePath ? {
|
|
299
|
+
source: await findUpdateSource(name$1, sourcePath),
|
|
300
|
+
origin: {
|
|
301
|
+
sourceType: "local",
|
|
302
|
+
sourcePath: resolve(sourcePath)
|
|
303
|
+
}
|
|
304
|
+
} : await prepareTrackedUpdate(current);
|
|
305
|
+
try {
|
|
306
|
+
return await this.replaceSkill(name$1, current, resolvedSource.source, resolvedSource.origin, projectRoot);
|
|
307
|
+
} finally {
|
|
308
|
+
await resolvedSource.cleanup?.();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async replaceSkill(name$1, current, source, origin, projectRoot) {
|
|
312
|
+
const currentTarget = join(this.library, current.layout === "file" ? `${name$1}.md` : name$1);
|
|
313
|
+
const nextTarget = join(this.library, source.layout === "file" ? `${name$1}.md` : name$1);
|
|
314
|
+
if (currentTarget !== nextTarget && await pathExists(nextTarget)) throw new Error(`cannot change skill layout because the target already exists: ${nextTarget}`);
|
|
315
|
+
const suffix = randomUUID();
|
|
316
|
+
const temp = `${nextTarget}.tmp-${suffix}`;
|
|
317
|
+
const backup = `${currentTarget}.bak-${suffix}`;
|
|
318
|
+
const previousState = this.state;
|
|
319
|
+
let currentMoved = false;
|
|
320
|
+
let nextMoved = false;
|
|
321
|
+
try {
|
|
322
|
+
await cp(source.path, temp, {
|
|
323
|
+
recursive: source.layout === "directory",
|
|
324
|
+
errorOnExist: true,
|
|
325
|
+
force: false,
|
|
326
|
+
verbatimSymlinks: true
|
|
327
|
+
});
|
|
328
|
+
const contentHash = await hashSkillSource(temp);
|
|
329
|
+
await rename(currentTarget, backup);
|
|
330
|
+
currentMoved = true;
|
|
331
|
+
await rename(temp, nextTarget);
|
|
332
|
+
nextMoved = true;
|
|
333
|
+
this.state = {
|
|
334
|
+
...this.state,
|
|
335
|
+
skills: {
|
|
336
|
+
...this.state.skills,
|
|
337
|
+
[name$1]: {
|
|
338
|
+
...current,
|
|
339
|
+
description: source.description,
|
|
340
|
+
layout: source.layout,
|
|
341
|
+
sourceType: origin.sourceType,
|
|
342
|
+
sourcePath: origin.sourcePath,
|
|
343
|
+
sourceUrl: origin.sourceUrl,
|
|
344
|
+
sourceSubpath: origin.sourceSubpath,
|
|
345
|
+
contentHash,
|
|
346
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
await this.persist();
|
|
351
|
+
await rm(backup, {
|
|
352
|
+
recursive: current.layout === "directory",
|
|
353
|
+
force: true
|
|
354
|
+
}).catch(() => void 0);
|
|
355
|
+
this.updateChecks.set(name$1, { available: false });
|
|
356
|
+
this.providerControl?.invalidate();
|
|
357
|
+
return this.catalog(projectRoot);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this.state = previousState;
|
|
360
|
+
if (nextMoved) await rm(nextTarget, {
|
|
361
|
+
recursive: source.layout === "directory",
|
|
362
|
+
force: true
|
|
363
|
+
});
|
|
364
|
+
if (currentMoved) await rename(backup, currentTarget);
|
|
365
|
+
await rm(temp, {
|
|
366
|
+
recursive: source.layout === "directory",
|
|
367
|
+
force: true
|
|
368
|
+
});
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async setEnabled(name$1, scope, projectRoot, enabled) {
|
|
373
|
+
await this.loaded;
|
|
374
|
+
this.state = setEnabled(this.state, name$1, scope, projectRoot, enabled);
|
|
375
|
+
await this.persist();
|
|
376
|
+
this.providerControl?.invalidate();
|
|
377
|
+
return this.catalog(projectRoot);
|
|
378
|
+
}
|
|
379
|
+
async remove(name$1, projectRoot) {
|
|
380
|
+
await this.loaded;
|
|
381
|
+
const skill = this.state.skills[name$1];
|
|
382
|
+
if (!skill) throw new Error(`skill is not installed: ${name$1}`);
|
|
383
|
+
await rm(join(this.library, skill.layout === "file" ? `${name$1}.md` : name$1), {
|
|
384
|
+
recursive: skill.layout === "directory",
|
|
385
|
+
force: true
|
|
386
|
+
});
|
|
387
|
+
this.state = removeSkill(this.state, name$1);
|
|
388
|
+
await this.persist();
|
|
389
|
+
this.updateChecks.delete(name$1);
|
|
390
|
+
this.providerControl?.invalidate();
|
|
391
|
+
return this.catalog(projectRoot);
|
|
392
|
+
}
|
|
393
|
+
async load() {
|
|
394
|
+
await mkdir(this.library, { recursive: true });
|
|
395
|
+
try {
|
|
396
|
+
this.state = normalizeState(JSON.parse(await readFile(this.stateFile, "utf8")));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
399
|
+
this.state = EMPTY_STATE;
|
|
400
|
+
await this.persist();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async persist() {
|
|
407
|
+
const operation = this.writeQueue.then(async () => {
|
|
408
|
+
await mkdir(this.root, { recursive: true });
|
|
409
|
+
const temp = `${this.stateFile}.tmp-${randomUUID()}`;
|
|
410
|
+
await writeFile(temp, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
|
|
411
|
+
await rename(temp, this.stateFile);
|
|
412
|
+
});
|
|
413
|
+
this.writeQueue = operation.catch(() => void 0);
|
|
414
|
+
await operation;
|
|
415
|
+
}
|
|
416
|
+
managedCandidate(candidate) {
|
|
417
|
+
return {
|
|
418
|
+
...candidate,
|
|
419
|
+
provider: "skill-manager",
|
|
420
|
+
source: "skill-manager",
|
|
421
|
+
rank: 50,
|
|
422
|
+
locator: { candidate }
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
function apply(ctx, config) {
|
|
427
|
+
if (config.storageDir?.trim() && !isAbsolute(config.storageDir)) throw new Error("storageDir must resolve to an absolute path");
|
|
428
|
+
const manager = new SkillManager(ctx, config);
|
|
429
|
+
ctx.skills.registerProvider((control) => manager.provider(control));
|
|
430
|
+
const host = ctx;
|
|
431
|
+
ctx.effect(() => host.connection.rpc.handle("/skill-manager", async (endpoint, payload) => handleRpc(manager, endpoint, payload), { authority: "loopback" }), "dsh-skill-manager: rpc");
|
|
432
|
+
}
|
|
433
|
+
async function handleRpc(manager, endpoint, payload) {
|
|
434
|
+
const args = isRecord(payload) ? payload : {};
|
|
435
|
+
switch (endpoint) {
|
|
436
|
+
case "list": return {
|
|
437
|
+
ok: true,
|
|
438
|
+
value: await manager.catalog(asOptionalString(args.projectRoot))
|
|
439
|
+
};
|
|
440
|
+
case "install": return {
|
|
441
|
+
ok: true,
|
|
442
|
+
value: await manager.install(requiredString(args.path, "path"), asOptionalString(args.projectRoot))
|
|
443
|
+
};
|
|
444
|
+
case "install-remote": return {
|
|
445
|
+
ok: true,
|
|
446
|
+
value: await manager.installRemote(requiredString(args.repository, "repository"), asOptionalString(args.projectRoot))
|
|
447
|
+
};
|
|
448
|
+
case "check-updates": return {
|
|
449
|
+
ok: true,
|
|
450
|
+
value: await manager.checkUpdates(asOptionalString(args.projectRoot))
|
|
451
|
+
};
|
|
452
|
+
case "update": return {
|
|
453
|
+
ok: true,
|
|
454
|
+
value: await manager.update(requiredString(args.name, "name"), asOptionalString(args.path), asOptionalString(args.projectRoot))
|
|
455
|
+
};
|
|
456
|
+
case "set-enabled": return {
|
|
457
|
+
ok: true,
|
|
458
|
+
value: await manager.setEnabled(requiredString(args.name, "name"), requiredScope(args.scope), asOptionalString(args.projectRoot), requiredBoolean(args.enabled, "enabled"))
|
|
459
|
+
};
|
|
460
|
+
case "remove": return {
|
|
461
|
+
ok: true,
|
|
462
|
+
value: await manager.remove(requiredString(args.name, "name"), asOptionalString(args.projectRoot))
|
|
463
|
+
};
|
|
464
|
+
default: throw new Error(`unknown skill-manager endpoint: ${endpoint}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function discoverSources(inputPath) {
|
|
468
|
+
const path = resolve(inputPath.trim());
|
|
469
|
+
const info = await stat(path);
|
|
470
|
+
if (info.isFile()) {
|
|
471
|
+
if (extname(path).toLowerCase() !== ".md") throw new Error("a skill file must use the .md extension");
|
|
472
|
+
return [await readSkillSource(path, "file")];
|
|
473
|
+
}
|
|
474
|
+
if (!info.isDirectory()) throw new Error("skill source must be a directory or Markdown file");
|
|
475
|
+
if (await isFile(join(path, "SKILL.md"))) return [await readSkillSource(join(path, "SKILL.md"), "directory", path)];
|
|
476
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
477
|
+
const sources = [];
|
|
478
|
+
for (const entry of entries) {
|
|
479
|
+
const child = join(path, entry.name);
|
|
480
|
+
if (entry.isDirectory() && await isFile(join(child, "SKILL.md"))) sources.push(await readSkillSource(join(child, "SKILL.md"), "directory", child));
|
|
481
|
+
else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") sources.push(await readSkillSource(child, "file"));
|
|
482
|
+
}
|
|
483
|
+
if (sources.length === 0) throw new Error("no SKILL.md or Markdown skill file was found in the selected folder");
|
|
484
|
+
return sources;
|
|
485
|
+
}
|
|
486
|
+
async function discoverRepositorySources(root) {
|
|
487
|
+
if (await isFile(join(root, "SKILL.md"))) return [await readSkillSource(join(root, "SKILL.md"), "directory", root)];
|
|
488
|
+
const searchRoots = [
|
|
489
|
+
root,
|
|
490
|
+
join(root, "skills"),
|
|
491
|
+
join(root, ".agents", "skills")
|
|
492
|
+
];
|
|
493
|
+
const sources = /* @__PURE__ */ new Map();
|
|
494
|
+
for (const searchRoot of searchRoots) {
|
|
495
|
+
let entries;
|
|
496
|
+
try {
|
|
497
|
+
entries = await readdir(searchRoot, { withFileTypes: true });
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (isNodeError(error) && error.code === "ENOENT") continue;
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
const child = join(searchRoot, entry.name);
|
|
504
|
+
if (entry.isDirectory() && await isFile(join(child, "SKILL.md"))) {
|
|
505
|
+
const source = await readSkillSource(join(child, "SKILL.md"), "directory", child);
|
|
506
|
+
sources.set(source.name, source);
|
|
507
|
+
} else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md" && entry.name.toLowerCase() !== "readme.md") try {
|
|
508
|
+
const source = await readSkillSource(child, "file");
|
|
509
|
+
sources.set(source.name, source);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if ((await readFile(child, "utf8")).startsWith("---")) throw error;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (sources.size === 0) throw new Error("the repository does not contain a root skill or skills/* entries");
|
|
516
|
+
return [...sources.values()];
|
|
517
|
+
}
|
|
518
|
+
function normalizeGitSource(input) {
|
|
519
|
+
const value = input.trim();
|
|
520
|
+
if (!value || value.startsWith("-") || /[\r\n\0]/.test(value)) throw new Error("repository must be a valid Git URL or owner/repo");
|
|
521
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) return `https://github.com/${value}.git`;
|
|
522
|
+
if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+:[^\s]+$/.test(value)) return value;
|
|
523
|
+
let url;
|
|
524
|
+
try {
|
|
525
|
+
url = new URL(value);
|
|
526
|
+
} catch {
|
|
527
|
+
throw new Error("repository must be a GitHub owner/repo, HTTPS URL, SSH URL, or file URL");
|
|
528
|
+
}
|
|
529
|
+
if (![
|
|
530
|
+
"https:",
|
|
531
|
+
"http:",
|
|
532
|
+
"ssh:",
|
|
533
|
+
"git:",
|
|
534
|
+
"file:"
|
|
535
|
+
].includes(url.protocol)) throw new Error(`unsupported Git protocol: ${url.protocol}`);
|
|
536
|
+
if (url.password || (url.protocol === "https:" || url.protocol === "http:") && url.username) throw new Error("do not embed credentials in the repository URL; use a Git credential helper or SSH");
|
|
537
|
+
if (url.hostname === "github.com" && /\/tree\//.test(url.pathname)) throw new Error("use the repository root URL, not a GitHub /tree/ URL");
|
|
538
|
+
return value;
|
|
539
|
+
}
|
|
540
|
+
async function cloneGitRepository(sourceUrl) {
|
|
541
|
+
const checkout = await mkdtemp(join(tmpdir(), "dsh-skill-manager-git-"));
|
|
542
|
+
try {
|
|
543
|
+
await execFileAsync("git", [
|
|
544
|
+
"clone",
|
|
545
|
+
"--depth",
|
|
546
|
+
"1",
|
|
547
|
+
"--",
|
|
548
|
+
sourceUrl,
|
|
549
|
+
checkout
|
|
550
|
+
], {
|
|
551
|
+
timeout: 12e4,
|
|
552
|
+
maxBuffer: 1024 * 1024
|
|
553
|
+
});
|
|
554
|
+
return checkout;
|
|
555
|
+
} catch {
|
|
556
|
+
await rm(checkout, {
|
|
557
|
+
recursive: true,
|
|
558
|
+
force: true
|
|
559
|
+
});
|
|
560
|
+
throw new Error("failed to clone Git repository; check the URL and local Git credentials");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function resolveTrackedSource(skill, checkoutFor) {
|
|
564
|
+
if (skill.sourceUrl) {
|
|
565
|
+
if (typeof skill.sourceSubpath !== "string") throw new Error("the repository skill path is missing; import it again");
|
|
566
|
+
const checkout = await checkoutFor(skill.sourceUrl);
|
|
567
|
+
return findUpdateSource(skill.name, resolveRepositorySubpath(checkout, skill.sourceSubpath));
|
|
568
|
+
}
|
|
569
|
+
return findUpdateSource(skill.name, skill.sourcePath);
|
|
570
|
+
}
|
|
571
|
+
async function prepareTrackedUpdate(skill) {
|
|
572
|
+
if (!skill.sourceUrl) return {
|
|
573
|
+
source: await findUpdateSource(skill.name, skill.sourcePath),
|
|
574
|
+
origin: {
|
|
575
|
+
sourceType: "local",
|
|
576
|
+
sourcePath: skill.sourcePath
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const checkout = await cloneGitRepository(skill.sourceUrl);
|
|
580
|
+
try {
|
|
581
|
+
return {
|
|
582
|
+
source: await resolveTrackedSource(skill, async () => checkout),
|
|
583
|
+
origin: {
|
|
584
|
+
sourceType: "git",
|
|
585
|
+
sourceUrl: skill.sourceUrl,
|
|
586
|
+
sourceSubpath: skill.sourceSubpath
|
|
587
|
+
},
|
|
588
|
+
cleanup: () => rm(checkout, {
|
|
589
|
+
recursive: true,
|
|
590
|
+
force: true
|
|
591
|
+
})
|
|
592
|
+
};
|
|
593
|
+
} catch (error) {
|
|
594
|
+
await rm(checkout, {
|
|
595
|
+
recursive: true,
|
|
596
|
+
force: true
|
|
597
|
+
});
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function resolveRepositorySubpath(root, subpath) {
|
|
602
|
+
const target = resolve(root, subpath);
|
|
603
|
+
const child = relative(root, target);
|
|
604
|
+
if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) throw new Error("repository skill path escapes the checkout");
|
|
605
|
+
return target;
|
|
606
|
+
}
|
|
607
|
+
async function findUpdateSource(name$1, sourcePath) {
|
|
608
|
+
if (!sourcePath) throw new Error("the original source is unknown; select the skill source folder to update it");
|
|
609
|
+
const source = (await discoverSources(sourcePath)).find((candidate) => candidate.name === name$1);
|
|
610
|
+
if (!source) throw new Error(`the selected source does not contain skill: ${name$1}`);
|
|
611
|
+
return source;
|
|
612
|
+
}
|
|
613
|
+
async function hashSkillSource(root) {
|
|
614
|
+
const hash = createHash("sha256");
|
|
615
|
+
const visit = async (path) => {
|
|
616
|
+
const info = await lstat(path);
|
|
617
|
+
const name$1 = relative(root, path).split(sep).join("/");
|
|
618
|
+
if (info.isSymbolicLink()) {
|
|
619
|
+
const target = await readlink(path);
|
|
620
|
+
const child = relative(root, resolve(dirname(path), target));
|
|
621
|
+
if (isAbsolute(target) || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) throw new Error(`skill symlink escapes its source root: ${path}`);
|
|
622
|
+
hash.update(`link\0${name$1}\0${target}\0`);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (info.isFile()) {
|
|
626
|
+
hash.update(`file\0${name$1}\0`);
|
|
627
|
+
hash.update(await readFile(path));
|
|
628
|
+
hash.update("\0");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (!info.isDirectory()) throw new Error(`unsupported skill entry: ${path}`);
|
|
632
|
+
hash.update(`directory\0${name$1}\0`);
|
|
633
|
+
const entries = await readdir(path);
|
|
634
|
+
entries.sort();
|
|
635
|
+
for (const entry of entries) await visit(join(path, entry));
|
|
636
|
+
};
|
|
637
|
+
await visit(root);
|
|
638
|
+
return hash.digest("hex");
|
|
639
|
+
}
|
|
640
|
+
async function pathExists(path) {
|
|
641
|
+
try {
|
|
642
|
+
await lstat(path);
|
|
643
|
+
return true;
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async function readSkillSource(markdownPath, layout, directoryPath = markdownPath) {
|
|
650
|
+
return {
|
|
651
|
+
path: layout === "directory" ? directoryPath : markdownPath,
|
|
652
|
+
layout,
|
|
653
|
+
...await parseSkillFrontmatter(markdownPath)
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
async function parseSkillFrontmatter(path) {
|
|
657
|
+
const text = await readFile(path, "utf8");
|
|
658
|
+
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(text);
|
|
659
|
+
if (!match) throw new Error(`missing YAML frontmatter: ${path}`);
|
|
660
|
+
const metadata = parse(match[1]);
|
|
661
|
+
if (!isRecord(metadata) || typeof metadata.name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(metadata.name) || typeof metadata.description !== "string" || !metadata.description.trim()) throw new Error(`invalid skill frontmatter: ${path}`);
|
|
662
|
+
return {
|
|
663
|
+
name: metadata.name,
|
|
664
|
+
description: metadata.description.trim()
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
async function isFile(path) {
|
|
668
|
+
try {
|
|
669
|
+
return (await stat(path)).isFile();
|
|
670
|
+
} catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function requiredString(value, field) {
|
|
675
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} must be a non-empty string`);
|
|
676
|
+
return value.trim();
|
|
677
|
+
}
|
|
678
|
+
function asOptionalString(value) {
|
|
679
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
680
|
+
}
|
|
681
|
+
function requiredBoolean(value, field) {
|
|
682
|
+
if (typeof value !== "boolean") throw new Error(`${field} must be boolean`);
|
|
683
|
+
return value;
|
|
684
|
+
}
|
|
685
|
+
function requiredScope(value) {
|
|
686
|
+
if (value !== "global" && value !== "project") throw new Error("scope must be global or project");
|
|
687
|
+
return value;
|
|
688
|
+
}
|
|
689
|
+
function isRecord(value) {
|
|
690
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
691
|
+
}
|
|
692
|
+
function messageOf(value) {
|
|
693
|
+
return value instanceof Error ? value.message : String(value);
|
|
694
|
+
}
|
|
695
|
+
function isNodeError(value) {
|
|
696
|
+
return value instanceof Error && "code" in value;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
//#endregion
|
|
700
|
+
export { Config, SkillManager, apply, inject, name };
|