@nanmicoder/dsh-skills-hub 0.0.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/AGENTS.md +9 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README_EN.md +120 -0
- package/README_ZH.md +5 -0
- package/assets/readme/hero-en.png +0 -0
- package/assets/readme/hero-zh.png +0 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +10 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/CONTRACT.md +125 -0
- package/docs/RELEASE.md +24 -0
- package/lib/client.js +25115 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +51 -0
- package/lib/index.js +176 -0
- package/lib/market/cache.js +113 -0
- package/lib/market/clawhub-provider.js +598 -0
- package/lib/market/install-service.js +394 -0
- package/lib/market/market-service.js +312 -0
- package/lib/market/provider-fetch.js +216 -0
- package/lib/market/skillhub-provider.js +277 -0
- package/lib/market/types.js +82 -0
- package/lib/skills/installed.js +426 -0
- package/lib/skills/management.js +36 -0
- package/lib/skills/read-text.js +23 -0
- package/lib/skills/root.js +112 -0
- package/lib/types/client/api.d.ts +106 -0
- package/lib/types/client/components/FilterBar.d.ts +20 -0
- package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
- package/lib/types/client/components/InstalledSkills.d.ts +3 -0
- package/lib/types/client/components/MarkdownView.d.ts +4 -0
- package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
- package/lib/types/client/components/MarketHome.d.ts +12 -0
- package/lib/types/client/components/SecurityBadge.d.ts +16 -0
- package/lib/types/client/components/SkillAvatar.d.ts +14 -0
- package/lib/types/client/components/SkillCard.d.ts +17 -0
- package/lib/types/client/components/SkillDetailView.d.ts +21 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
- package/lib/types/client/icons.d.ts +71 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locale-context.d.ts +31 -0
- package/lib/types/client/locales.d.ts +117 -0
- package/lib/types/client/page/MarketPage.d.ts +21 -0
- package/lib/types/client/skill-markdown.d.ts +2 -0
- package/lib/types/client/state.d.ts +119 -0
- package/lib/types/config.d.ts +51 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/market/cache.d.ts +49 -0
- package/lib/types/market/clawhub-provider.d.ts +28 -0
- package/lib/types/market/install-service.d.ts +75 -0
- package/lib/types/market/market-service.d.ts +74 -0
- package/lib/types/market/provider-fetch.d.ts +61 -0
- package/lib/types/market/skillhub-provider.d.ts +13 -0
- package/lib/types/market/types.d.ts +163 -0
- package/lib/types/skills/installed.d.ts +117 -0
- package/lib/types/skills/management.d.ts +7 -0
- package/lib/types/skills/read-text.d.ts +5 -0
- package/lib/types/skills/root.d.ts +48 -0
- package/lib/types/web-routes.d.ts +74 -0
- package/lib/web-routes.js +427 -0
- package/package.json +160 -0
- package/release-notes/v0.0.1.md +11 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installed-skill index.
|
|
3
|
+
*
|
|
4
|
+
* This module is the filesystem truth behind two things the market layer cannot
|
|
5
|
+
* know by itself:
|
|
6
|
+
*
|
|
7
|
+
* - `InstalledLookup` — which market ids (`source:slug`) are already present
|
|
8
|
+
* locally, so the list/detail responses can report `installState` and the
|
|
9
|
+
* installer can refuse to clobber a directory it does not own.
|
|
10
|
+
* - `GET /api/skills-hub/installed` — the panel's "installed" filter, including
|
|
11
|
+
* skills the user dropped into the skills directory by hand.
|
|
12
|
+
*
|
|
13
|
+
* A directory is *managed* when it carries the provenance sidecar
|
|
14
|
+
* ({@link INSTALL_META_FILE}) written by the installer. Nothing else in the tree
|
|
15
|
+
* is overwritten by the market installer; local entries can be explicitly
|
|
16
|
+
* removed through the installed-management endpoint after user confirmation.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs/promises';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { readBoundedText } from "./read-text.js";
|
|
22
|
+
import { MARKET_SOURCES, skillId } from "../market/types.js";
|
|
23
|
+
/** Provenance sidecar written next to `SKILL.md` by the installer. */
|
|
24
|
+
export const INSTALL_META_FILE = '.skills-hub.json';
|
|
25
|
+
/** Longest description kept as a card `summary`; the long form lives in SKILL.md. */
|
|
26
|
+
const MAX_SUMMARY_LENGTH = 280;
|
|
27
|
+
/** Bound on the size walk so a pathological tree cannot stall a scan. */
|
|
28
|
+
const MAX_WALK_ENTRIES = 5_000;
|
|
29
|
+
/**
|
|
30
|
+
* Validate and normalize a sidecar payload.
|
|
31
|
+
*
|
|
32
|
+
* The identity fields are checked against each other (`id` must be
|
|
33
|
+
* `skillId(source, slug)`) because a sidecar that disagrees with itself cannot
|
|
34
|
+
* authorize a delete: the reference implementation's "not managed" branch
|
|
35
|
+
* exists for exactly this class of tampering, and the same check is reused on
|
|
36
|
+
* the uninstall path.
|
|
37
|
+
*
|
|
38
|
+
* @param value - parsed JSON of unknown shape.
|
|
39
|
+
* @returns the normalized sidecar, or `null` when it does not prove ownership.
|
|
40
|
+
*/
|
|
41
|
+
export function parseInstalledMeta(value) {
|
|
42
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
43
|
+
return null;
|
|
44
|
+
const candidate = value;
|
|
45
|
+
const source = candidate['source'];
|
|
46
|
+
const slug = candidate['slug'];
|
|
47
|
+
const id = candidate['id'];
|
|
48
|
+
const installedAt = candidate['installedAt'];
|
|
49
|
+
if (typeof source !== 'string' || !MARKET_SOURCES.includes(source))
|
|
50
|
+
return null;
|
|
51
|
+
if (typeof slug !== 'string' || slug === '')
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof id !== 'string' || id !== skillId(source, slug))
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof installedAt !== 'string' || installedAt === '')
|
|
56
|
+
return null;
|
|
57
|
+
const version = typeof candidate['version'] === 'string' && candidate['version'] !== ''
|
|
58
|
+
? candidate['version']
|
|
59
|
+
: undefined;
|
|
60
|
+
const files = [];
|
|
61
|
+
const rawFiles = candidate['files'];
|
|
62
|
+
if (Array.isArray(rawFiles)) {
|
|
63
|
+
for (const entry of rawFiles) {
|
|
64
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
65
|
+
continue;
|
|
66
|
+
const record = entry;
|
|
67
|
+
const filePath = record['path'];
|
|
68
|
+
const sha256 = record['sha256'];
|
|
69
|
+
if (typeof filePath === 'string' && filePath !== '' && typeof sha256 === 'string' && sha256 !== '') {
|
|
70
|
+
files.push({ path: filePath, sha256 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
source: source,
|
|
77
|
+
slug,
|
|
78
|
+
...(version === undefined ? {} : { version }),
|
|
79
|
+
installedAt,
|
|
80
|
+
files,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Collapse a YAML description into one bounded line for card display. */
|
|
84
|
+
function summarize(description) {
|
|
85
|
+
const collapsed = description.replace(/\s+/g, ' ').trim();
|
|
86
|
+
return collapsed.length > MAX_SUMMARY_LENGTH ? `${collapsed.slice(0, MAX_SUMMARY_LENGTH - 1)}…` : collapsed;
|
|
87
|
+
}
|
|
88
|
+
/** Strip one pair of surrounding quotes from a YAML scalar. */
|
|
89
|
+
function stripQuotes(value) {
|
|
90
|
+
const trimmed = value.trim();
|
|
91
|
+
if (trimmed.length >= 2) {
|
|
92
|
+
const first = trimmed.charAt(0);
|
|
93
|
+
const last = trimmed.charAt(trimmed.length - 1);
|
|
94
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'"))
|
|
95
|
+
return trimmed.slice(1, -1);
|
|
96
|
+
}
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read the handful of frontmatter keys the index needs.
|
|
101
|
+
*
|
|
102
|
+
* A deliberately small YAML subset (plain scalars, quoted scalars, `|`/`>`
|
|
103
|
+
* block scalars, and `metadata.version`) — the full document is the skill
|
|
104
|
+
* provider's business, and this module must not pull a YAML parser into the
|
|
105
|
+
* plugin's dependency set. Anything it cannot read is simply absent: a skill
|
|
106
|
+
* with unparsable frontmatter still shows up under its directory name.
|
|
107
|
+
*/
|
|
108
|
+
function parseFrontmatter(markdown) {
|
|
109
|
+
const text = markdown.startsWith('\uFEFF') ? markdown.slice(1) : markdown;
|
|
110
|
+
const lines = text.split(/\r?\n/);
|
|
111
|
+
if (lines[0]?.trim() !== '---')
|
|
112
|
+
return {};
|
|
113
|
+
let end = -1;
|
|
114
|
+
for (let index = 1; index < lines.length; index++) {
|
|
115
|
+
if (lines[index]?.trim() === '---') {
|
|
116
|
+
end = index;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (end === -1)
|
|
121
|
+
return {};
|
|
122
|
+
const result = {};
|
|
123
|
+
let topKey;
|
|
124
|
+
let block;
|
|
125
|
+
const assign = (key, value) => {
|
|
126
|
+
const normalized = stripQuotes(value);
|
|
127
|
+
if (normalized === '')
|
|
128
|
+
return;
|
|
129
|
+
if (key === 'name' && result.name === undefined)
|
|
130
|
+
result.name = normalized;
|
|
131
|
+
else if (key === 'description' && result.description === undefined)
|
|
132
|
+
result.description = normalized;
|
|
133
|
+
else if (key === 'version' && result.version === undefined)
|
|
134
|
+
result.version = normalized;
|
|
135
|
+
};
|
|
136
|
+
const flushBlock = () => {
|
|
137
|
+
if (block === undefined)
|
|
138
|
+
return;
|
|
139
|
+
assign(block.key, block.lines.join(block.literal ? '\n' : ' '));
|
|
140
|
+
block = undefined;
|
|
141
|
+
};
|
|
142
|
+
for (let index = 1; index < end; index++) {
|
|
143
|
+
const raw = lines[index] ?? '';
|
|
144
|
+
const trimmed = raw.trim();
|
|
145
|
+
const indent = raw.length - raw.trimStart().length;
|
|
146
|
+
if (block !== undefined) {
|
|
147
|
+
// A block scalar ends at the first non-empty line that dedents to column 0.
|
|
148
|
+
if (trimmed === '' || indent > 0) {
|
|
149
|
+
block.lines.push(trimmed);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
flushBlock();
|
|
153
|
+
}
|
|
154
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
155
|
+
continue;
|
|
156
|
+
if (indent > 0) {
|
|
157
|
+
// Nested mapping: only `metadata.version` is part of the index.
|
|
158
|
+
if (topKey === 'metadata' && result.version === undefined) {
|
|
159
|
+
const separator = trimmed.indexOf(':');
|
|
160
|
+
if (separator > 0 && trimmed.slice(0, separator).trim() === 'version') {
|
|
161
|
+
assign('version', trimmed.slice(separator + 1));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const separator = trimmed.indexOf(':');
|
|
167
|
+
if (separator <= 0)
|
|
168
|
+
continue;
|
|
169
|
+
const key = trimmed.slice(0, separator).trim();
|
|
170
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
171
|
+
topKey = key;
|
|
172
|
+
if (value === '')
|
|
173
|
+
continue;
|
|
174
|
+
if (/^[|>][+-]?$/.test(value)) {
|
|
175
|
+
block = { key, literal: value.startsWith('|'), lines: [] };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
assign(key, value);
|
|
179
|
+
}
|
|
180
|
+
flushBlock();
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
/** Read a text file, returning `undefined` for anything unreadable. */
|
|
184
|
+
async function readTextIfExists(filePath) {
|
|
185
|
+
try {
|
|
186
|
+
return (await readBoundedText(filePath, 64 * 1024)).text;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Read and parse a JSON file, returning `undefined` for absent or invalid content. */
|
|
193
|
+
async function readJsonIfExists(filePath) {
|
|
194
|
+
try {
|
|
195
|
+
const result = await readBoundedText(filePath, 2 * 1024 * 1024);
|
|
196
|
+
if (result.truncated)
|
|
197
|
+
return undefined;
|
|
198
|
+
return JSON.parse(result.text);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Measure a skill directory without leaving it.
|
|
206
|
+
*
|
|
207
|
+
* Symlinks and special files are skipped rather than followed: an installed
|
|
208
|
+
* skill must never be able to pull the size walk (or anything else) outside its
|
|
209
|
+
* own directory. The sidecar itself is not part of the installed payload, so it
|
|
210
|
+
* is excluded from both counters.
|
|
211
|
+
*/
|
|
212
|
+
async function measureDirectory(dirPath) {
|
|
213
|
+
let bytes = 0;
|
|
214
|
+
let fileCount = 0;
|
|
215
|
+
let visited = 0;
|
|
216
|
+
const pending = [dirPath];
|
|
217
|
+
while (pending.length > 0 && visited < MAX_WALK_ENTRIES) {
|
|
218
|
+
const current = pending.pop();
|
|
219
|
+
if (current === undefined)
|
|
220
|
+
break;
|
|
221
|
+
let entries;
|
|
222
|
+
try {
|
|
223
|
+
// A directory replaced by a symlink after enumeration is not followed.
|
|
224
|
+
if (current !== dirPath && !(await fs.lstat(current)).isDirectory())
|
|
225
|
+
continue;
|
|
226
|
+
entries = await fs.opendir(current);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
for await (const entry of entries) {
|
|
232
|
+
if (visited++ >= MAX_WALK_ENTRIES)
|
|
233
|
+
break;
|
|
234
|
+
const entryPath = path.join(current, entry.name);
|
|
235
|
+
if (entry.isDirectory()) {
|
|
236
|
+
pending.push(entryPath);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!entry.isFile())
|
|
240
|
+
continue;
|
|
241
|
+
if (current === dirPath && entry.name === INSTALL_META_FILE)
|
|
242
|
+
continue;
|
|
243
|
+
try {
|
|
244
|
+
const stats = await fs.lstat(entryPath);
|
|
245
|
+
if (stats.isFile()) {
|
|
246
|
+
bytes += stats.size;
|
|
247
|
+
fileCount++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Raced with a concurrent removal — count what is still there.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { bytes, fileCount };
|
|
256
|
+
}
|
|
257
|
+
/** Build the record for one directory-bundle skill, or `undefined` when it is not one. */
|
|
258
|
+
async function readDirectorySkill(rootPath, dirName) {
|
|
259
|
+
const dirPath = path.join(rootPath, dirName);
|
|
260
|
+
const markdown = await readTextIfExists(path.join(dirPath, 'SKILL.md'));
|
|
261
|
+
if (markdown === undefined)
|
|
262
|
+
return undefined;
|
|
263
|
+
const meta = parseInstalledMeta(await readJsonIfExists(path.join(dirPath, INSTALL_META_FILE)));
|
|
264
|
+
const frontmatter = parseFrontmatter(markdown);
|
|
265
|
+
const measured = await measureDirectory(dirPath);
|
|
266
|
+
const shared = {
|
|
267
|
+
key: installedEntryKey(dirPath),
|
|
268
|
+
linked: false,
|
|
269
|
+
removable: true,
|
|
270
|
+
name: frontmatter.name ?? dirName,
|
|
271
|
+
dirName,
|
|
272
|
+
dirPath,
|
|
273
|
+
...(frontmatter.description === undefined ? {} : { summary: summarize(frontmatter.description) }),
|
|
274
|
+
managed: meta !== null,
|
|
275
|
+
bytes: measured.bytes,
|
|
276
|
+
fileCount: measured.fileCount,
|
|
277
|
+
};
|
|
278
|
+
if (meta === null) {
|
|
279
|
+
return {
|
|
280
|
+
id: `local:${dirName}`,
|
|
281
|
+
source: 'local',
|
|
282
|
+
slug: dirName,
|
|
283
|
+
...(frontmatter.version === undefined ? {} : { version: frontmatter.version }),
|
|
284
|
+
...shared,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const version = meta.version ?? frontmatter.version;
|
|
288
|
+
return {
|
|
289
|
+
id: meta.id,
|
|
290
|
+
source: meta.source,
|
|
291
|
+
slug: meta.slug,
|
|
292
|
+
...(version === undefined ? {} : { version }),
|
|
293
|
+
installedAt: meta.installedAt,
|
|
294
|
+
...shared,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** Build the record for one flat `<name>.md` skill. */
|
|
298
|
+
async function readFlatSkill(rootPath, fileName) {
|
|
299
|
+
const filePath = path.join(rootPath, fileName);
|
|
300
|
+
const markdown = await readTextIfExists(filePath);
|
|
301
|
+
if (markdown === undefined)
|
|
302
|
+
return undefined;
|
|
303
|
+
const frontmatter = parseFrontmatter(markdown);
|
|
304
|
+
const dirName = fileName.slice(0, -'.md'.length);
|
|
305
|
+
let bytes = 0;
|
|
306
|
+
try {
|
|
307
|
+
bytes = (await fs.stat(filePath)).size;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Raced with a concurrent removal.
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
key: installedEntryKey(filePath),
|
|
314
|
+
linked: false,
|
|
315
|
+
removable: true,
|
|
316
|
+
id: `local:${dirName}`,
|
|
317
|
+
source: 'local',
|
|
318
|
+
slug: dirName,
|
|
319
|
+
name: frontmatter.name ?? dirName,
|
|
320
|
+
dirName,
|
|
321
|
+
dirPath: filePath,
|
|
322
|
+
...(frontmatter.description === undefined ? {} : { summary: summarize(frontmatter.description) }),
|
|
323
|
+
...(frontmatter.version === undefined ? {} : { version: frontmatter.version }),
|
|
324
|
+
// A flat skill has no market provenance sidecar.
|
|
325
|
+
managed: false,
|
|
326
|
+
bytes,
|
|
327
|
+
fileCount: markdown === '' ? 0 : 1,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Scan every root for installed skills, most specific root first.
|
|
332
|
+
*
|
|
333
|
+
* Each filesystem entry remains individually addressable for management; the
|
|
334
|
+
* market lookup separately gives earlier roots precedence. Dot-prefixed entries are skipped — DSH's own
|
|
335
|
+
* filesystem provider skips `.system` in the user-dsh root, and the installer's
|
|
336
|
+
* staging/trash directories are dot-prefixed so no scanner ever sees a
|
|
337
|
+
* half-published skill.
|
|
338
|
+
*
|
|
339
|
+
* @param roots - absolute skill roots, most specific first (see `resolveSkillsScanRoots`).
|
|
340
|
+
* @returns one record per filesystem entry, in scan order.
|
|
341
|
+
*/
|
|
342
|
+
export async function scanInstalledSkills(roots) {
|
|
343
|
+
const records = [];
|
|
344
|
+
for (const root of roots) {
|
|
345
|
+
const resolvedRoot = path.resolve(root);
|
|
346
|
+
let entries;
|
|
347
|
+
try {
|
|
348
|
+
entries = await fs.readdir(resolvedRoot, { withFileTypes: true });
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// A root that does not exist yet simply contributes nothing.
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
355
|
+
for (const entry of entries) {
|
|
356
|
+
if (entry.name.startsWith('.'))
|
|
357
|
+
continue;
|
|
358
|
+
const entryPath = path.join(resolvedRoot, entry.name);
|
|
359
|
+
const kind = entry.isSymbolicLink() ? await fs.stat(entryPath).catch(() => undefined) : entry;
|
|
360
|
+
const record = kind?.isDirectory()
|
|
361
|
+
? await readDirectorySkill(resolvedRoot, entry.name)
|
|
362
|
+
: kind?.isFile() && entry.name.endsWith('.md')
|
|
363
|
+
? await readFlatSkill(resolvedRoot, entry.name)
|
|
364
|
+
: undefined;
|
|
365
|
+
if (record === undefined)
|
|
366
|
+
continue;
|
|
367
|
+
record.linked = entry.isSymbolicLink();
|
|
368
|
+
records.push(record);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return records;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Read just the display identity of a skill file.
|
|
375
|
+
*
|
|
376
|
+
* Used by the uninstall path, which must describe a skill after its directory is
|
|
377
|
+
* gone: the frontmatter has to be captured while the file still exists, and it
|
|
378
|
+
* must not fail the removal when it cannot be read.
|
|
379
|
+
*
|
|
380
|
+
* @param skillFile - absolute path of a `SKILL.md`.
|
|
381
|
+
* @returns `name`/`summary` as far as they could be read, or `undefined`.
|
|
382
|
+
*/
|
|
383
|
+
export async function readSkillHeadline(skillFile) {
|
|
384
|
+
const markdown = await readTextIfExists(skillFile);
|
|
385
|
+
if (markdown === undefined)
|
|
386
|
+
return undefined;
|
|
387
|
+
const frontmatter = parseFrontmatter(markdown);
|
|
388
|
+
return {
|
|
389
|
+
...(frontmatter.name === undefined ? {} : { name: frontmatter.name }),
|
|
390
|
+
...(frontmatter.description === undefined ? {} : { summary: summarize(frontmatter.description) }),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Project scanned records into the market layer's installed-id seam.
|
|
395
|
+
*
|
|
396
|
+
* The lookup is a pure function of the records so the caller can rebuild it
|
|
397
|
+
* after an install/uninstall without any shared mutable state.
|
|
398
|
+
*
|
|
399
|
+
* @param records - output of {@link scanInstalledSkills}.
|
|
400
|
+
* @returns the lookup W1's `setInstalledLookup()` expects.
|
|
401
|
+
*/
|
|
402
|
+
export function installedLookupFrom(records) {
|
|
403
|
+
const byId = new Map();
|
|
404
|
+
for (const record of records)
|
|
405
|
+
if (!byId.has(record.id))
|
|
406
|
+
byId.set(record.id, record);
|
|
407
|
+
return {
|
|
408
|
+
has(id) {
|
|
409
|
+
return byId.has(id);
|
|
410
|
+
},
|
|
411
|
+
info(id) {
|
|
412
|
+
const record = byId.get(id);
|
|
413
|
+
if (record === undefined)
|
|
414
|
+
return undefined;
|
|
415
|
+
return {
|
|
416
|
+
dirName: record.dirName,
|
|
417
|
+
...(record.version === undefined ? {} : { version: record.version }),
|
|
418
|
+
...(record.installedAt === undefined ? {} : { installedAt: record.installedAt }),
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/** Path-derived identifiers never expose a caller-controlled filesystem path. */
|
|
424
|
+
export function installedEntryKey(entryPath) {
|
|
425
|
+
return createHash('sha256').update(path.resolve(entryPath)).digest('hex');
|
|
426
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Local management only addresses entries freshly discovered inside configured user roots. */
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { readBoundedText } from "./read-text.js";
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { MarketInstallError } from "../market/install-service.js";
|
|
7
|
+
import { scanInstalledSkills } from "./installed.js";
|
|
8
|
+
async function resolveEntry(roots, key) {
|
|
9
|
+
if (!/^[a-f0-9]{64}$/.test(key))
|
|
10
|
+
throw new MarketInstallError(400, 'BAD_REQUEST', 'Invalid installed skill key');
|
|
11
|
+
const entry = (await scanInstalledSkills(roots)).find(item => item.key === key);
|
|
12
|
+
if (!entry)
|
|
13
|
+
throw new MarketInstallError(404, 'MARKET_NOT_INSTALLED', 'Installed skill no longer exists');
|
|
14
|
+
return entry;
|
|
15
|
+
}
|
|
16
|
+
export async function readInstalledSkill(roots, key) {
|
|
17
|
+
const item = await resolveEntry(roots, key);
|
|
18
|
+
const stats = await fs.stat(item.dirPath);
|
|
19
|
+
const file = stats.isDirectory() ? path.join(item.dirPath, 'SKILL.md') : item.dirPath;
|
|
20
|
+
const content = await readBoundedText(file, 2 * 1024 * 1024);
|
|
21
|
+
if (content.truncated) {
|
|
22
|
+
throw new MarketInstallError(413, 'FILE_TOO_LARGE', 'Skill preview exceeds 2 MB');
|
|
23
|
+
}
|
|
24
|
+
return { item, markdown: content.text };
|
|
25
|
+
}
|
|
26
|
+
/** Rename the selected entry out of discovery before deletion. Symlink targets are never removed. */
|
|
27
|
+
export async function removeInstalledSkill(roots, key, allowUninstall) {
|
|
28
|
+
if (!allowUninstall)
|
|
29
|
+
throw new MarketInstallError(405, 'METHOD_NOT_ALLOWED', 'Uninstalling skills is disabled by the Skills Hub configuration');
|
|
30
|
+
const item = await resolveEntry(roots, key);
|
|
31
|
+
const trash = path.join(path.dirname(item.dirPath), `.skills-hub-trash-${randomUUID()}`);
|
|
32
|
+
await fs.rename(item.dirPath, trash);
|
|
33
|
+
// rm does not follow symlinks, including links swapped in after discovery.
|
|
34
|
+
await fs.rm(trash, { recursive: true, force: true });
|
|
35
|
+
return item;
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
/** Read at most limit + 1 bytes; reject special files and detect growth without an unbounded allocation. */
|
|
3
|
+
export async function readBoundedText(filePath, limit) {
|
|
4
|
+
if (!(await fs.stat(filePath)).isFile())
|
|
5
|
+
throw new Error('Skill document is not a regular file');
|
|
6
|
+
const handle = await fs.open(filePath, 'r');
|
|
7
|
+
try {
|
|
8
|
+
if (!(await handle.stat()).isFile())
|
|
9
|
+
throw new Error('Skill document is not a regular file');
|
|
10
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
11
|
+
let size = 0;
|
|
12
|
+
while (size < buffer.length) {
|
|
13
|
+
const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size);
|
|
14
|
+
if (bytesRead === 0)
|
|
15
|
+
break;
|
|
16
|
+
size += bytesRead;
|
|
17
|
+
}
|
|
18
|
+
return { text: buffer.subarray(0, Math.min(size, limit)).toString('utf8'), truncated: size > limit };
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await handle.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills-directory resolution.
|
|
3
|
+
*
|
|
4
|
+
* Two different questions are answered here, and they must not be conflated:
|
|
5
|
+
*
|
|
6
|
+
* - {@link resolveSkillsRoot} — *where do we install?* Exactly one directory:
|
|
7
|
+
* the configured override, or the user skills directory of the harness home.
|
|
8
|
+
* - {@link resolveSkillsScanRoots} — *what does "already installed" mean?*
|
|
9
|
+
* Every directory the local filesystem skill provider scans for user-level
|
|
10
|
+
* skills, most specific first, so a skill that is already present anywhere in
|
|
11
|
+
* the DSH user skill roots is reported as installed instead of being
|
|
12
|
+
* downloaded and clobbered.
|
|
13
|
+
*
|
|
14
|
+
* `$DSH_HOME` is resolved here instead of through `@deepseek-ai/dsh-home-paths`
|
|
15
|
+
* on purpose: that package is a dev-only dependency of this plugin (it is not in
|
|
16
|
+
* `peerDependencies`), so a runtime value import would make the published plugin
|
|
17
|
+
* fail to load in a profile where it is not hoisted. The resolution rules below
|
|
18
|
+
* are the documented ones from
|
|
19
|
+
* `@deepseek-ai/dsh-home-paths/lib/types/index.d.ts` (0.1.7-rc.2):
|
|
20
|
+
* explicit path → `DSH_HOME` (blank treated as unset) → `~/.dsh`, plus the
|
|
21
|
+
* `~`, `~/`, `~\` tilde expansion of `expandHomePath`.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'node:fs/promises';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
/** Environment variable that overrides the default harness home (dsh-home-paths). */
|
|
27
|
+
const DSH_HOME_ENV = 'DSH_HOME';
|
|
28
|
+
/** Directory name of the default harness home under the OS home (dsh-home-paths). */
|
|
29
|
+
const DSH_HOME_DIR_NAME = '.dsh';
|
|
30
|
+
/** Environment variable that overrides the shared agents home (dsh-skill-filesystem). */
|
|
31
|
+
const DSH_AGENTS_HOME_ENV = 'DSH_AGENTS_HOME';
|
|
32
|
+
/** Directory name of the default shared agents home under the OS home. */
|
|
33
|
+
const AGENTS_HOME_DIR_NAME = '.agents';
|
|
34
|
+
/**
|
|
35
|
+
* Expand the tilde prefixes the harness accepts (`~`, `~/`, `~\`) against the OS
|
|
36
|
+
* home; every other value is returned unchanged.
|
|
37
|
+
*/
|
|
38
|
+
function expandHomePath(value) {
|
|
39
|
+
if (value === '~')
|
|
40
|
+
return homedir();
|
|
41
|
+
if (value.startsWith('~/') || value.startsWith('~\\'))
|
|
42
|
+
return path.join(homedir(), value.slice(2));
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the single-root harness home.
|
|
47
|
+
*
|
|
48
|
+
* Precedence, highest first: an explicit path, `$DSH_HOME`, then `~/.dsh`. A
|
|
49
|
+
* blank `$DSH_HOME` is treated as unset so a stray empty variable can never
|
|
50
|
+
* resolve the harness home to the current working directory.
|
|
51
|
+
*/
|
|
52
|
+
function resolveDshHome(configured) {
|
|
53
|
+
if (typeof configured === 'string' && configured.trim() !== '') {
|
|
54
|
+
return path.resolve(expandHomePath(configured.trim()));
|
|
55
|
+
}
|
|
56
|
+
const fromEnv = process.env[DSH_HOME_ENV];
|
|
57
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
|
|
58
|
+
return path.resolve(expandHomePath(fromEnv.trim()));
|
|
59
|
+
}
|
|
60
|
+
return path.join(homedir(), DSH_HOME_DIR_NAME);
|
|
61
|
+
}
|
|
62
|
+
/** The harness home's shared user skills directory (`$DSH_HOME/skills`). */
|
|
63
|
+
function dshUserSkillsDir() {
|
|
64
|
+
return path.join(resolveDshHome(), 'skills');
|
|
65
|
+
}
|
|
66
|
+
/** The cross-agent user skills directory (`$DSH_AGENTS_HOME/skills`, else `~/.agents/skills`). */
|
|
67
|
+
function agentsUserSkillsDir() {
|
|
68
|
+
const fromEnv = process.env[DSH_AGENTS_HOME_ENV];
|
|
69
|
+
const home = typeof fromEnv === 'string' && fromEnv.trim() !== ''
|
|
70
|
+
? path.resolve(expandHomePath(fromEnv.trim()))
|
|
71
|
+
: path.join(homedir(), AGENTS_HOME_DIR_NAME);
|
|
72
|
+
return path.join(home, 'skills');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Absolute skills directory skills are installed into, created on demand.
|
|
76
|
+
*
|
|
77
|
+
* A relative configured path is resolved against the host process's working
|
|
78
|
+
* directory: a skills root must be absolute before any containment check can
|
|
79
|
+
* mean anything, and refusing relative input outright would break the common
|
|
80
|
+
* `./tmp-skills` test setup.
|
|
81
|
+
*
|
|
82
|
+
* @param configured - `Config.skillsRoot`; `null`/blank selects the DSH user skills directory.
|
|
83
|
+
* @returns the absolute, existing skills directory.
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveSkillsRoot(configured) {
|
|
86
|
+
const root = typeof configured === 'string' && configured.trim() !== ''
|
|
87
|
+
? path.resolve(expandHomePath(configured.trim()))
|
|
88
|
+
: dshUserSkillsDir();
|
|
89
|
+
await fs.mkdir(root, { recursive: true });
|
|
90
|
+
return root;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Every directory DSH will scan for skills, most specific first (installed lookup).
|
|
94
|
+
*
|
|
95
|
+
* Order is the install root, then the two user roots the local filesystem skill
|
|
96
|
+
* provider mounts by default (`$DSH_HOME/skills` with `user-dsh` rank, then
|
|
97
|
+
* `$DSH_AGENTS_HOME/skills` with `user-agents` rank — see
|
|
98
|
+
* `@deepseek-ai/dsh-skill-filesystem/lib/index.js#roots`). Project roots
|
|
99
|
+
* (`.dsh/skills`, `.agents/skills` under a session's cwd) are deliberately
|
|
100
|
+
* absent: this plugin runs at host scope, has no session, and must not claim
|
|
101
|
+
* that a skill in whichever directory the server happened to start in is
|
|
102
|
+
* "installed" for every session. Duplicates (a configured root that *is* the
|
|
103
|
+
* harness home) collapse to their first, most specific occurrence.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveSkillsScanRoots(configured) {
|
|
106
|
+
const roots = [await resolveSkillsRoot(configured)];
|
|
107
|
+
for (const candidate of [dshUserSkillsDir(), agentsUserSkillsDir()]) {
|
|
108
|
+
if (!roots.includes(candidate))
|
|
109
|
+
roots.push(candidate);
|
|
110
|
+
}
|
|
111
|
+
return roots;
|
|
112
|
+
}
|