dsh-skill-importer 0.1.2 → 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/README.md +10 -4
- package/README.zh.md +11 -5
- package/lib/client.js +1142 -304
- package/lib/index.js +307 -13
- package/lib/types/client/index.d.ts +7 -1
- package/lib/types/client/locales.d.ts +56 -8
- package/lib/types/index.d.ts +5 -1
- package/lib/types/server.d.ts +30 -1
- package/lib/types/types.d.ts +48 -3
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, extname, isAbsolute, join, relative } from "node:path";
|
|
4
5
|
//#region src/frontmatter.ts
|
|
5
6
|
/** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
|
|
6
7
|
const KEBAB_CASE$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -91,6 +92,9 @@ function parseSkillFile(text) {
|
|
|
91
92
|
const MAX_CONTENT_BYTES = 262144;
|
|
92
93
|
/** Cap for one request body read (JSON overhead above the content cap). */
|
|
93
94
|
const MAX_BODY_BYTES = 1048576;
|
|
95
|
+
const MAX_BATCH_FILES_PER_SKILL = 2e3;
|
|
96
|
+
const MAX_BATCH_SKILL_BYTES = 10485760;
|
|
97
|
+
const BATCH_SCAN_TTL_MS = 6e5;
|
|
94
98
|
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
95
99
|
/** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
|
|
96
100
|
function dshHomeDir() {
|
|
@@ -101,21 +105,15 @@ function skillRoot(target, workspacePath) {
|
|
|
101
105
|
switch (target) {
|
|
102
106
|
case "user": return join(dshHomeDir(), "skills");
|
|
103
107
|
case "project-agents": return join(workspacePath, ".agents", "skills");
|
|
104
|
-
case "project-dsh": return join(workspacePath, ".dsh", "skills");
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
/** Discovery rank per root (lower wins), mirroring dsh-skill-filesystem. */
|
|
108
111
|
const ROOT_RANK = {
|
|
109
|
-
"project-dsh": 100,
|
|
110
112
|
"project-agents": 200,
|
|
111
113
|
user: 400
|
|
112
114
|
};
|
|
113
115
|
/** Scan order: project roots first so project skills win duplicate names. */
|
|
114
|
-
const SCAN_ORDER = [
|
|
115
|
-
"project-dsh",
|
|
116
|
-
"project-agents",
|
|
117
|
-
"user"
|
|
118
|
-
];
|
|
116
|
+
const SCAN_ORDER = ["project-agents", "user"];
|
|
119
117
|
/**
|
|
120
118
|
* Quote one frontmatter string value when plain YAML would mis-parse it:
|
|
121
119
|
* a value containing `: ` (or any colon), leading/trailing whitespace, a
|
|
@@ -240,6 +238,239 @@ function deleteSkillFile(name, source, workspacePath) {
|
|
|
240
238
|
}
|
|
241
239
|
return false;
|
|
242
240
|
}
|
|
241
|
+
/** Hash names and bytes while refusing symlinks and oversized skill bundles. */
|
|
242
|
+
function sourceStats(sourcePath, kind) {
|
|
243
|
+
const hash = createHash("sha256");
|
|
244
|
+
let fileCount = 0;
|
|
245
|
+
let bytes = 0;
|
|
246
|
+
const visit = (path, relativePath) => {
|
|
247
|
+
const stat = lstatSync(path);
|
|
248
|
+
if (stat.isSymbolicLink()) throw new Error("技能目录不能包含符号链接");
|
|
249
|
+
if (stat.isDirectory()) {
|
|
250
|
+
for (const entry of readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) visit(join(path, entry.name), relativePath.length === 0 ? entry.name : join(relativePath, entry.name));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!stat.isFile()) throw new Error("技能目录包含不支持的文件类型");
|
|
254
|
+
fileCount += 1;
|
|
255
|
+
bytes += stat.size;
|
|
256
|
+
if (fileCount > 2e3) throw new Error(`技能文件数量超过 ${MAX_BATCH_FILES_PER_SKILL}`);
|
|
257
|
+
if (bytes > 10485760) throw new Error(`技能目录超过 ${MAX_BATCH_SKILL_BYTES / 1024 / 1024} MB`);
|
|
258
|
+
hash.update(relativePath);
|
|
259
|
+
hash.update("\0");
|
|
260
|
+
hash.update(readFileSync(path));
|
|
261
|
+
hash.update("\0");
|
|
262
|
+
};
|
|
263
|
+
visit(sourcePath, kind === "file" ? basename(sourcePath) : "");
|
|
264
|
+
return {
|
|
265
|
+
fingerprint: hash.digest("hex"),
|
|
266
|
+
fileCount,
|
|
267
|
+
bytes
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function destinationExists(name, target, workspacePath) {
|
|
271
|
+
const root = skillRoot(target, workspacePath ?? "");
|
|
272
|
+
return existsSync(join(root, name)) || existsSync(join(root, `${name}.md`));
|
|
273
|
+
}
|
|
274
|
+
/** Resolve immediate child skill directories and flat Markdown skills. */
|
|
275
|
+
function batchSources(root) {
|
|
276
|
+
const ownSkill = join(root, "SKILL.md");
|
|
277
|
+
if (existsSync(ownSkill)) return [{
|
|
278
|
+
path: root,
|
|
279
|
+
kind: "directory",
|
|
280
|
+
label: basename(root)
|
|
281
|
+
}];
|
|
282
|
+
const sources = [];
|
|
283
|
+
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
284
|
+
const path = join(root, entry.name);
|
|
285
|
+
if (entry.isSymbolicLink()) continue;
|
|
286
|
+
if (entry.isDirectory() && existsSync(join(path, "SKILL.md"))) sources.push({
|
|
287
|
+
path,
|
|
288
|
+
kind: "directory",
|
|
289
|
+
label: entry.name
|
|
290
|
+
});
|
|
291
|
+
else if (entry.isFile() && [".md", ".markdown"].includes(extname(entry.name).toLowerCase())) sources.push({
|
|
292
|
+
path,
|
|
293
|
+
kind: "file",
|
|
294
|
+
label: basename(entry.name, extname(entry.name))
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return sources;
|
|
298
|
+
}
|
|
299
|
+
/** Validate a selected skills root without writing anything. */
|
|
300
|
+
function scanBatch(request) {
|
|
301
|
+
if (!isAbsolute(request.sourcePath)) throw new Error("批量导入目录必须是绝对路径");
|
|
302
|
+
if (!existsSync(request.sourcePath) || !statSync(request.sourcePath).isDirectory()) throw new Error("批量导入目录不存在或不可读");
|
|
303
|
+
if (request.target !== "user" && request.workspacePath === void 0) throw new Error("项目目标需要 workspacePath(当前工作区路径)");
|
|
304
|
+
const sources = batchSources(request.sourcePath);
|
|
305
|
+
if (sources.length === 0) throw new Error("所选目录中没有找到可导入的技能");
|
|
306
|
+
if (sources.length > 200) throw new Error(`一次最多扫描 200 个技能`);
|
|
307
|
+
const entries = [];
|
|
308
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
309
|
+
const nameRows = /* @__PURE__ */ new Map();
|
|
310
|
+
for (const [index, source] of sources.entries()) {
|
|
311
|
+
const id = String(index + 1);
|
|
312
|
+
const relativePath = relative(request.sourcePath, source.path) || ".";
|
|
313
|
+
try {
|
|
314
|
+
const skillFile = source.kind === "directory" ? join(source.path, "SKILL.md") : source.path;
|
|
315
|
+
const text = readFileSync(skillFile, "utf8");
|
|
316
|
+
if (Buffer.byteLength(text, "utf8") > 262144) throw new Error(`SKILL.md 超过 ${MAX_CONTENT_BYTES / 1024} KB`);
|
|
317
|
+
const { frontmatter } = parseSkillFile(text);
|
|
318
|
+
if (frontmatter.name === void 0) throw new Error("frontmatter 缺少 name 字段");
|
|
319
|
+
if (!isValidSkillName(frontmatter.name)) throw new Error("name 必须是 kebab-case(小写字母、数字、短横线)");
|
|
320
|
+
if (frontmatter.description === void 0 || frontmatter.description.trim().length === 0) throw new Error("frontmatter 缺少 description 字段");
|
|
321
|
+
normalizeSkillText(text);
|
|
322
|
+
const stats = sourceStats(source.path, source.kind);
|
|
323
|
+
const warnings = source.label === frontmatter.name ? void 0 : [`目录或文件名“${source.label}”与技能名“${frontmatter.name}”不一致`];
|
|
324
|
+
entries.push({
|
|
325
|
+
id,
|
|
326
|
+
name: frontmatter.name,
|
|
327
|
+
description: frontmatter.description,
|
|
328
|
+
relativePath,
|
|
329
|
+
status: "ready",
|
|
330
|
+
conflict: destinationExists(frontmatter.name, request.target, request.workspacePath),
|
|
331
|
+
...warnings === void 0 ? {} : { warnings }
|
|
332
|
+
});
|
|
333
|
+
candidates.set(id, {
|
|
334
|
+
id,
|
|
335
|
+
name: frontmatter.name,
|
|
336
|
+
description: frontmatter.description,
|
|
337
|
+
sourcePath: source.path,
|
|
338
|
+
sourceKind: source.kind,
|
|
339
|
+
fingerprint: stats.fingerprint
|
|
340
|
+
});
|
|
341
|
+
const at = entries.length - 1;
|
|
342
|
+
nameRows.set(frontmatter.name, [...nameRows.get(frontmatter.name) ?? [], at]);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
entries.push({
|
|
345
|
+
id,
|
|
346
|
+
name: source.label,
|
|
347
|
+
relativePath,
|
|
348
|
+
status: "error",
|
|
349
|
+
error: error instanceof Error ? error.message : String(error),
|
|
350
|
+
conflict: false
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const [name, indexes] of nameRows) {
|
|
355
|
+
if (indexes.length < 2) continue;
|
|
356
|
+
for (const index of indexes) {
|
|
357
|
+
const entry = entries[index];
|
|
358
|
+
if (entry === void 0) continue;
|
|
359
|
+
entries[index] = {
|
|
360
|
+
...entry,
|
|
361
|
+
status: "error",
|
|
362
|
+
conflict: false,
|
|
363
|
+
error: `批次内存在重复技能名:${name}`
|
|
364
|
+
};
|
|
365
|
+
candidates.delete(entry.id);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
scanId: randomUUID(),
|
|
370
|
+
sourcePath: request.sourcePath,
|
|
371
|
+
target: request.target,
|
|
372
|
+
...request.workspacePath === void 0 ? {} : { workspacePath: request.workspacePath },
|
|
373
|
+
expiresAt: Date.now() + BATCH_SCAN_TTL_MS,
|
|
374
|
+
entries,
|
|
375
|
+
candidates
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/** Copy one validated candidate into a same-root staging directory, then atomically swap it in. */
|
|
379
|
+
function installBatchCandidate(candidate, session, replace) {
|
|
380
|
+
if (sourceStats(candidate.sourcePath, candidate.sourceKind).fingerprint !== candidate.fingerprint) throw new Error("源技能在确认期间发生变化,请重新扫描");
|
|
381
|
+
const root = skillRoot(session.target, session.workspacePath ?? "");
|
|
382
|
+
mkdirSync(root, { recursive: true });
|
|
383
|
+
const finalDirectory = join(root, candidate.name);
|
|
384
|
+
const conflicts = [finalDirectory, join(root, `${candidate.name}.md`)].filter(existsSync);
|
|
385
|
+
if (conflicts.length > 0 && !replace) throw new Error("目标中已存在同名技能");
|
|
386
|
+
const staging = join(root, `.${candidate.name}.import-${randomUUID()}`);
|
|
387
|
+
const backups = [];
|
|
388
|
+
try {
|
|
389
|
+
if (candidate.sourceKind === "directory") {
|
|
390
|
+
cpSync(candidate.sourcePath, staging, {
|
|
391
|
+
recursive: true,
|
|
392
|
+
errorOnExist: true,
|
|
393
|
+
dereference: false
|
|
394
|
+
});
|
|
395
|
+
const skillFile = join(staging, "SKILL.md");
|
|
396
|
+
writeFileSync(skillFile, normalizeSkillText(readFileSync(skillFile, "utf8")), "utf8");
|
|
397
|
+
} else {
|
|
398
|
+
mkdirSync(staging, { recursive: false });
|
|
399
|
+
writeFileSync(join(staging, "SKILL.md"), normalizeSkillText(readFileSync(candidate.sourcePath, "utf8")), "utf8");
|
|
400
|
+
}
|
|
401
|
+
for (const original of conflicts) {
|
|
402
|
+
const backup = join(root, `.${basename(original)}.backup-${randomUUID()}`);
|
|
403
|
+
renameSync(original, backup);
|
|
404
|
+
backups.push({
|
|
405
|
+
original,
|
|
406
|
+
backup
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
renameSync(staging, finalDirectory);
|
|
410
|
+
for (const { backup } of backups) rmSync(backup, {
|
|
411
|
+
recursive: true,
|
|
412
|
+
force: true
|
|
413
|
+
});
|
|
414
|
+
return conflicts.length > 0 ? "replaced" : "imported";
|
|
415
|
+
} catch (error) {
|
|
416
|
+
rmSync(staging, {
|
|
417
|
+
recursive: true,
|
|
418
|
+
force: true
|
|
419
|
+
});
|
|
420
|
+
if (existsSync(finalDirectory) && backups.length > 0) rmSync(finalDirectory, {
|
|
421
|
+
recursive: true,
|
|
422
|
+
force: true
|
|
423
|
+
});
|
|
424
|
+
for (const { original, backup } of backups.reverse()) if (existsSync(backup)) renameSync(backup, original);
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Commit a valid, unexpired scan once; invalid rows are returned as errors and never written. */
|
|
429
|
+
function commitBatch(session, replaceNames) {
|
|
430
|
+
if (Date.now() > session.expiresAt) throw new Error("批量扫描结果已过期,请重新扫描");
|
|
431
|
+
const results = [];
|
|
432
|
+
for (const entry of session.entries) {
|
|
433
|
+
if (entry.status === "error") {
|
|
434
|
+
results.push({
|
|
435
|
+
name: entry.name,
|
|
436
|
+
status: "error",
|
|
437
|
+
message: entry.error
|
|
438
|
+
});
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const candidate = session.candidates.get(entry.id);
|
|
442
|
+
if (candidate === void 0) {
|
|
443
|
+
results.push({
|
|
444
|
+
name: entry.name,
|
|
445
|
+
status: "error",
|
|
446
|
+
message: "扫描记录不完整,请重新扫描"
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const conflict = destinationExists(entry.name, session.target, session.workspacePath);
|
|
451
|
+
if (conflict && !replaceNames.has(entry.name)) {
|
|
452
|
+
results.push({
|
|
453
|
+
name: entry.name,
|
|
454
|
+
status: "skipped",
|
|
455
|
+
message: "目标中已存在同名技能,未选择替换"
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
results.push({
|
|
461
|
+
name: entry.name,
|
|
462
|
+
status: installBatchCandidate(candidate, session, conflict && replaceNames.has(entry.name))
|
|
463
|
+
});
|
|
464
|
+
} catch (error) {
|
|
465
|
+
results.push({
|
|
466
|
+
name: entry.name,
|
|
467
|
+
status: "error",
|
|
468
|
+
message: error instanceof Error ? error.message : String(error)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return results;
|
|
473
|
+
}
|
|
243
474
|
/** Strip HTML down to a plain-Markdown-ish text body (best effort). */
|
|
244
475
|
function extractHtmlText(html) {
|
|
245
476
|
const withoutBlocks = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
|
|
@@ -338,7 +569,9 @@ const ROUTES = {
|
|
|
338
569
|
list: "/skill-importer/list",
|
|
339
570
|
import: "/skill-importer/import",
|
|
340
571
|
importUrl: "/skill-importer/import-url",
|
|
341
|
-
delete: "/skill-importer/delete"
|
|
572
|
+
delete: "/skill-importer/delete",
|
|
573
|
+
batchScan: "/skill-importer/batch/scan",
|
|
574
|
+
batchCommit: "/skill-importer/batch/commit"
|
|
342
575
|
};
|
|
343
576
|
/** Required services: the harness web server's route registry and the workspace registry. */
|
|
344
577
|
const inject = ["webServer", "workspaceRegistry"];
|
|
@@ -354,6 +587,11 @@ const handle = (fn) => (req, res) => {
|
|
|
354
587
|
*/
|
|
355
588
|
function apply(ctx) {
|
|
356
589
|
const projectPaths = () => ctx.workspaceRegistry.list().map((workspace) => workspace.path);
|
|
590
|
+
const batchScans = /* @__PURE__ */ new Map();
|
|
591
|
+
const pruneBatchScans = () => {
|
|
592
|
+
const now = Date.now();
|
|
593
|
+
for (const [id, session] of batchScans) if (session.expiresAt <= now) batchScans.delete(id);
|
|
594
|
+
};
|
|
357
595
|
const routes = [
|
|
358
596
|
{
|
|
359
597
|
kind: "exact",
|
|
@@ -379,7 +617,7 @@ function apply(ctx) {
|
|
|
379
617
|
return;
|
|
380
618
|
}
|
|
381
619
|
const body = await readJsonBody(req);
|
|
382
|
-
if (typeof body.name !== "string" || typeof body.content !== "string" || body.target !== "user" && body.target !== "project-agents"
|
|
620
|
+
if (typeof body.name !== "string" || typeof body.content !== "string" || body.target !== "user" && body.target !== "project-agents") {
|
|
383
621
|
sendError(res, 400, "请求缺少 name / content / target 字段");
|
|
384
622
|
return;
|
|
385
623
|
}
|
|
@@ -404,7 +642,7 @@ function apply(ctx) {
|
|
|
404
642
|
return;
|
|
405
643
|
}
|
|
406
644
|
const body = await readJsonBody(req);
|
|
407
|
-
if (typeof body.name !== "string" || typeof body.url !== "string" || body.target !== "user" && body.target !== "project-agents"
|
|
645
|
+
if (typeof body.name !== "string" || typeof body.url !== "string" || body.target !== "user" && body.target !== "project-agents") {
|
|
408
646
|
sendError(res, 400, "请求缺少 name / url / target 字段");
|
|
409
647
|
return;
|
|
410
648
|
}
|
|
@@ -429,7 +667,7 @@ function apply(ctx) {
|
|
|
429
667
|
return;
|
|
430
668
|
}
|
|
431
669
|
const body = await readJsonBody(req);
|
|
432
|
-
if (typeof body.name !== "string" || body.source !== "user" && body.source !== "project-agents"
|
|
670
|
+
if (typeof body.name !== "string" || body.source !== "user" && body.source !== "project-agents") {
|
|
433
671
|
sendError(res, 400, "请求缺少 name / source 字段");
|
|
434
672
|
return;
|
|
435
673
|
}
|
|
@@ -439,6 +677,62 @@ function apply(ctx) {
|
|
|
439
677
|
removed: deleteSkillFile(body.name, body.source, workspacePath)
|
|
440
678
|
});
|
|
441
679
|
})
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
kind: "exact",
|
|
683
|
+
path: ROUTES.batchScan,
|
|
684
|
+
handler: handle(async (req, res) => {
|
|
685
|
+
if (!originAllowed(req)) {
|
|
686
|
+
sendError(res, 403, "origin 不被允许");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const body = await readJsonBody(req);
|
|
690
|
+
if (typeof body.sourcePath !== "string" || body.target !== "user" && body.target !== "project-agents") {
|
|
691
|
+
sendError(res, 400, "请求缺少 sourcePath / target 字段");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const workspacePath = requireWorkspace(body.target, body.workspacePath);
|
|
695
|
+
pruneBatchScans();
|
|
696
|
+
const session = scanBatch({
|
|
697
|
+
sourcePath: body.sourcePath,
|
|
698
|
+
target: body.target,
|
|
699
|
+
workspacePath
|
|
700
|
+
});
|
|
701
|
+
batchScans.set(session.scanId, session);
|
|
702
|
+
sendJson(res, 200, {
|
|
703
|
+
ok: true,
|
|
704
|
+
scanId: session.scanId,
|
|
705
|
+
sourcePath: session.sourcePath,
|
|
706
|
+
entries: session.entries
|
|
707
|
+
});
|
|
708
|
+
})
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
kind: "exact",
|
|
712
|
+
path: ROUTES.batchCommit,
|
|
713
|
+
handler: handle(async (req, res) => {
|
|
714
|
+
if (!originAllowed(req)) {
|
|
715
|
+
sendError(res, 403, "origin 不被允许");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const body = await readJsonBody(req);
|
|
719
|
+
if (typeof body.scanId !== "string" || !Array.isArray(body.replace) || !body.replace.every((name) => typeof name === "string")) {
|
|
720
|
+
sendError(res, 400, "请求缺少 scanId / replace 字段");
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
pruneBatchScans();
|
|
724
|
+
const session = batchScans.get(body.scanId);
|
|
725
|
+
if (session === void 0) {
|
|
726
|
+
sendError(res, 410, "批量扫描结果不存在或已过期,请重新扫描");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
batchScans.delete(body.scanId);
|
|
730
|
+
const allowedNames = new Set(session.entries.filter((entry) => entry.status === "ready" && entry.conflict).map((entry) => entry.name));
|
|
731
|
+
sendJson(res, 200, {
|
|
732
|
+
ok: true,
|
|
733
|
+
results: commitBatch(session, new Set(body.replace.filter((name) => allowedNames.has(name))))
|
|
734
|
+
});
|
|
735
|
+
})
|
|
442
736
|
}
|
|
443
737
|
];
|
|
444
738
|
/**
|
|
@@ -3,7 +3,7 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
|
|
|
3
3
|
import type { SkillImporterProps } from './SkillImporterSection.tsx';
|
|
4
4
|
import type { SkillsPickerProps } from './SkillsPicker.tsx';
|
|
5
5
|
import { type SkillImporterKey } from './locales.ts';
|
|
6
|
-
import type { DeleteRequest, ImportRequest, ImportUrlRequest, SkillListResponse } from '../types.ts';
|
|
6
|
+
import type { BatchCommitRequest, BatchCommitResponse, BatchScanRequest, BatchScanResponse, DeleteRequest, ImportRequest, ImportUrlRequest, SkillListResponse } from '../types.ts';
|
|
7
7
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
8
8
|
interface LocaleNamespaceMap {
|
|
9
9
|
/** The skill importer section's copy. */
|
|
@@ -27,6 +27,12 @@ export interface SkillImporterInjected {
|
|
|
27
27
|
importUrl: (request: ImportUrlRequest) => Promise<string>;
|
|
28
28
|
/** Delete one installed skill copy via the host route. */
|
|
29
29
|
deleteSkill: (request: DeleteRequest) => Promise<boolean>;
|
|
30
|
+
/** Open dsh's native host directory chooser. */
|
|
31
|
+
pickDirectory: () => Promise<string | null>;
|
|
32
|
+
/** Validate one local skills root without writing. */
|
|
33
|
+
scanBatch: (request: BatchScanRequest) => Promise<BatchScanResponse>;
|
|
34
|
+
/** Commit one preflight once, replacing only explicitly confirmed names. */
|
|
35
|
+
commitBatch: (request: BatchCommitRequest) => Promise<BatchCommitResponse>;
|
|
30
36
|
};
|
|
31
37
|
}
|
|
32
38
|
/** Required services (cordis fiber inject). */
|
|
@@ -20,9 +20,6 @@ export declare const zh: {
|
|
|
20
20
|
installedTitle: string;
|
|
21
21
|
refresh: string;
|
|
22
22
|
installedEmpty: string;
|
|
23
|
-
sourceAgents: string;
|
|
24
|
-
sourceDsh: string;
|
|
25
|
-
sourceUser: string;
|
|
26
23
|
delete: string;
|
|
27
24
|
deleteConfirm: string;
|
|
28
25
|
deleteFailed: string;
|
|
@@ -31,9 +28,18 @@ export declare const zh: {
|
|
|
31
28
|
userOnly: string;
|
|
32
29
|
modelOnly: string;
|
|
33
30
|
importTitle: string;
|
|
31
|
+
importDescription: string;
|
|
34
32
|
fileTab: string;
|
|
35
33
|
urlTab: string;
|
|
34
|
+
batchTab: string;
|
|
35
|
+
fileEntryDescription: string;
|
|
36
|
+
urlEntryDescription: string;
|
|
37
|
+
batchEntryDescription: string;
|
|
36
38
|
chooseFile: string;
|
|
39
|
+
filePickerHint: string;
|
|
40
|
+
replaceFile: string;
|
|
41
|
+
browse: string;
|
|
42
|
+
change: string;
|
|
37
43
|
fileHelp: string;
|
|
38
44
|
previewTitle: string;
|
|
39
45
|
previewName: string;
|
|
@@ -48,10 +54,28 @@ export declare const zh: {
|
|
|
48
54
|
urlLabel: string;
|
|
49
55
|
urlNameLabel: string;
|
|
50
56
|
urlNameHelp: string;
|
|
57
|
+
batchChooseDirectory: string;
|
|
58
|
+
batchDirectoryHint: string;
|
|
59
|
+
batchDirectorySelected: string;
|
|
60
|
+
batchPickFailed: string;
|
|
61
|
+
batchScan: string;
|
|
62
|
+
batchWorking: string;
|
|
63
|
+
batchCommit: string;
|
|
64
|
+
batchScanFailed: string;
|
|
65
|
+
batchImportFailed: string;
|
|
66
|
+
batchPreflight: string;
|
|
67
|
+
batchConflict: string;
|
|
68
|
+
batchReplace: string;
|
|
69
|
+
batchConfirm: string;
|
|
70
|
+
batchResultTitle: string;
|
|
71
|
+
batchResultSummary: string;
|
|
72
|
+
batchStatusImported: string;
|
|
73
|
+
batchStatusReplaced: string;
|
|
74
|
+
batchStatusSkipped: string;
|
|
75
|
+
batchStatusError: string;
|
|
51
76
|
targetLabel: string;
|
|
52
77
|
targetUser: string;
|
|
53
78
|
targetProjectAgents: string;
|
|
54
|
-
targetProjectDsh: string;
|
|
55
79
|
targetProjectNoWorkspace: string;
|
|
56
80
|
noWorkspace: string;
|
|
57
81
|
import: string;
|
|
@@ -75,9 +99,6 @@ export declare const en: {
|
|
|
75
99
|
installedTitle: string;
|
|
76
100
|
refresh: string;
|
|
77
101
|
installedEmpty: string;
|
|
78
|
-
sourceAgents: string;
|
|
79
|
-
sourceDsh: string;
|
|
80
|
-
sourceUser: string;
|
|
81
102
|
delete: string;
|
|
82
103
|
deleteConfirm: string;
|
|
83
104
|
deleteFailed: string;
|
|
@@ -86,9 +107,18 @@ export declare const en: {
|
|
|
86
107
|
userOnly: string;
|
|
87
108
|
modelOnly: string;
|
|
88
109
|
importTitle: string;
|
|
110
|
+
importDescription: string;
|
|
89
111
|
fileTab: string;
|
|
90
112
|
urlTab: string;
|
|
113
|
+
batchTab: string;
|
|
114
|
+
fileEntryDescription: string;
|
|
115
|
+
urlEntryDescription: string;
|
|
116
|
+
batchEntryDescription: string;
|
|
91
117
|
chooseFile: string;
|
|
118
|
+
filePickerHint: string;
|
|
119
|
+
replaceFile: string;
|
|
120
|
+
browse: string;
|
|
121
|
+
change: string;
|
|
92
122
|
fileHelp: string;
|
|
93
123
|
previewTitle: string;
|
|
94
124
|
previewName: string;
|
|
@@ -103,10 +133,28 @@ export declare const en: {
|
|
|
103
133
|
urlLabel: string;
|
|
104
134
|
urlNameLabel: string;
|
|
105
135
|
urlNameHelp: string;
|
|
136
|
+
batchChooseDirectory: string;
|
|
137
|
+
batchDirectoryHint: string;
|
|
138
|
+
batchDirectorySelected: string;
|
|
139
|
+
batchPickFailed: string;
|
|
140
|
+
batchScan: string;
|
|
141
|
+
batchWorking: string;
|
|
142
|
+
batchCommit: string;
|
|
143
|
+
batchScanFailed: string;
|
|
144
|
+
batchImportFailed: string;
|
|
145
|
+
batchPreflight: string;
|
|
146
|
+
batchConflict: string;
|
|
147
|
+
batchReplace: string;
|
|
148
|
+
batchConfirm: string;
|
|
149
|
+
batchResultTitle: string;
|
|
150
|
+
batchResultSummary: string;
|
|
151
|
+
batchStatusImported: string;
|
|
152
|
+
batchStatusReplaced: string;
|
|
153
|
+
batchStatusSkipped: string;
|
|
154
|
+
batchStatusError: string;
|
|
106
155
|
targetLabel: string;
|
|
107
156
|
targetUser: string;
|
|
108
157
|
targetProjectAgents: string;
|
|
109
|
-
targetProjectDsh: string;
|
|
110
158
|
targetProjectNoWorkspace: string;
|
|
111
159
|
noWorkspace: string;
|
|
112
160
|
import: string;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host (Node) half of the dsh-skill-importer plugin.
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
4
|
+
* Registers the `/skill-importer/*` HTTP routes on the harness's own web
|
|
5
5
|
* server (`ctx.webServer` — the official plugin route registry, served on
|
|
6
6
|
* the same origin as the Web UI):
|
|
7
7
|
*
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* - POST /skill-importer/import — write one skill file from its text
|
|
11
11
|
* - POST /skill-importer/import-url — fetch a URL and write the skill file
|
|
12
12
|
* - POST /skill-importer/delete — remove one installed skill copy
|
|
13
|
+
* - POST /skill-importer/batch/scan — validate a local skills directory without writing
|
|
14
|
+
* - POST /skill-importer/batch/commit — commit one short-lived preflight once
|
|
13
15
|
*
|
|
14
16
|
* The host process owns the filesystem (no agent sandbox, no approval), so
|
|
15
17
|
* an import lands the file immediately; the skill-filesystem provider's
|
|
@@ -24,6 +26,8 @@ export declare const ROUTES: {
|
|
|
24
26
|
readonly import: "/skill-importer/import";
|
|
25
27
|
readonly importUrl: "/skill-importer/import-url";
|
|
26
28
|
readonly delete: "/skill-importer/delete";
|
|
29
|
+
readonly batchScan: "/skill-importer/batch/scan";
|
|
30
|
+
readonly batchCommit: "/skill-importer/batch/commit";
|
|
27
31
|
};
|
|
28
32
|
/** Required services: the harness web server's route registry and the workspace registry. */
|
|
29
33
|
export declare const inject: string[];
|
package/lib/types/server.d.ts
CHANGED
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
* and the skill-filesystem watcher discovers it in place.
|
|
9
9
|
*/
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
11
|
-
import type { ImportRequest, ImportTarget, ImportUrlRequest, SkillListEntry } from './types.ts';
|
|
11
|
+
import type { BatchCommitEntry, BatchScanEntry, BatchScanRequest, ImportRequest, ImportTarget, ImportUrlRequest, SkillListEntry } from './types.ts';
|
|
12
12
|
/** Hard cap for one imported skill body (matches the client preview limit). */
|
|
13
13
|
export declare const MAX_CONTENT_BYTES: number;
|
|
14
14
|
/** Cap for one request body read (JSON overhead above the content cap). */
|
|
15
15
|
export declare const MAX_BODY_BYTES: number;
|
|
16
|
+
/** Batch safety bounds: resources are copied, but one selection stays finite. */
|
|
17
|
+
export declare const MAX_BATCH_SKILLS = 200;
|
|
18
|
+
export declare const MAX_BATCH_FILES_PER_SKILL = 2000;
|
|
19
|
+
export declare const MAX_BATCH_SKILL_BYTES: number;
|
|
20
|
+
export declare const BATCH_SCAN_TTL_MS: number;
|
|
16
21
|
/** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
|
|
17
22
|
export declare function dshHomeDir(): string;
|
|
18
23
|
/** Absolute skill root for one target under one workspace. */
|
|
@@ -53,6 +58,29 @@ export declare function listSkills(workspacePaths: readonly string[]): SkillList
|
|
|
53
58
|
* @returns true when something was removed, false when nothing matched.
|
|
54
59
|
*/
|
|
55
60
|
export declare function deleteSkillFile(name: string, source: ImportTarget, workspacePath?: string): boolean;
|
|
61
|
+
/** Host-only source candidate retained between scan and the one-time commit. */
|
|
62
|
+
interface BatchCandidate {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly description: string;
|
|
66
|
+
readonly sourcePath: string;
|
|
67
|
+
readonly sourceKind: 'directory' | 'file';
|
|
68
|
+
readonly fingerprint: string;
|
|
69
|
+
}
|
|
70
|
+
/** One short-lived preflight. The HTTP layer owns the map and single-use lifecycle. */
|
|
71
|
+
export interface BatchScanSession {
|
|
72
|
+
readonly scanId: string;
|
|
73
|
+
readonly sourcePath: string;
|
|
74
|
+
readonly target: ImportTarget;
|
|
75
|
+
readonly workspacePath?: string;
|
|
76
|
+
readonly expiresAt: number;
|
|
77
|
+
readonly entries: readonly BatchScanEntry[];
|
|
78
|
+
readonly candidates: ReadonlyMap<string, BatchCandidate>;
|
|
79
|
+
}
|
|
80
|
+
/** Validate a selected skills root without writing anything. */
|
|
81
|
+
export declare function scanBatch(request: BatchScanRequest): BatchScanSession;
|
|
82
|
+
/** Commit a valid, unexpired scan once; invalid rows are returned as errors and never written. */
|
|
83
|
+
export declare function commitBatch(session: BatchScanSession, replaceNames: ReadonlySet<string>): BatchCommitEntry[];
|
|
56
84
|
/**
|
|
57
85
|
* Fetch a URL's content for import. Markdown/plain responses pass through
|
|
58
86
|
* verbatim; HTML is roughly extracted to text (the import UI recommends
|
|
@@ -81,3 +109,4 @@ export declare function originAllowed(req: IncomingMessage): boolean;
|
|
|
81
109
|
export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
|
|
82
110
|
/** Send a JSON error response with the given status. */
|
|
83
111
|
export declare function sendError(res: ServerResponse, status: number, error: string): void;
|
|
112
|
+
export {};
|
package/lib/types/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* browser bundle can reference them safely.
|
|
5
5
|
*/
|
|
6
6
|
/** Where the imported skill file should land. */
|
|
7
|
-
export type ImportTarget = 'user' | 'project-agents'
|
|
7
|
+
export type ImportTarget = 'user' | 'project-agents';
|
|
8
8
|
/** One installed skill row served by `/skill-importer/list`. */
|
|
9
9
|
export interface SkillListEntry {
|
|
10
10
|
/** Kebab-case skill name (frontmatter `name`). */
|
|
@@ -17,7 +17,7 @@ export interface SkillListEntry {
|
|
|
17
17
|
readonly modelInvocable: boolean;
|
|
18
18
|
/** False marks a model-only skill (`user-invocable: false`). */
|
|
19
19
|
readonly userInvocable: boolean;
|
|
20
|
-
/**
|
|
20
|
+
/** Skill root (`'project-agents'` or `'user'`). */
|
|
21
21
|
readonly source: ImportTarget;
|
|
22
22
|
}
|
|
23
23
|
/** File-import request body. */
|
|
@@ -32,7 +32,7 @@ export interface ImportRequest {
|
|
|
32
32
|
/** Delete-request body. */
|
|
33
33
|
export interface DeleteRequest {
|
|
34
34
|
readonly name: string;
|
|
35
|
-
/** Which root the copy lives in ('
|
|
35
|
+
/** Which root the copy lives in (`'project-agents'` or `'user'`). */
|
|
36
36
|
readonly source: ImportTarget;
|
|
37
37
|
/** Canonical workspace path for project sources (host-validated). */
|
|
38
38
|
readonly workspacePath?: string;
|
|
@@ -46,6 +46,51 @@ export interface ImportUrlRequest {
|
|
|
46
46
|
/** Canonical workspace path for project targets (host-validated against the registry). */
|
|
47
47
|
readonly workspacePath?: string;
|
|
48
48
|
}
|
|
49
|
+
/** Read-only preflight request for one local skills directory. */
|
|
50
|
+
export interface BatchScanRequest {
|
|
51
|
+
readonly sourcePath: string;
|
|
52
|
+
readonly target: ImportTarget;
|
|
53
|
+
readonly workspacePath?: string;
|
|
54
|
+
}
|
|
55
|
+
/** One source entry reported by batch preflight. */
|
|
56
|
+
export interface BatchScanEntry {
|
|
57
|
+
/** Stable row id within this one scan. */
|
|
58
|
+
readonly id: string;
|
|
59
|
+
/** Frontmatter name when readable, otherwise a best-effort source label. */
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly description?: string;
|
|
62
|
+
/** Path relative to the selected source directory. */
|
|
63
|
+
readonly relativePath: string;
|
|
64
|
+
/** Error rows can never be selected for commit. */
|
|
65
|
+
readonly status: 'ready' | 'error';
|
|
66
|
+
readonly error?: string;
|
|
67
|
+
readonly warnings?: readonly string[];
|
|
68
|
+
/** The selected destination already contains this skill name. */
|
|
69
|
+
readonly conflict: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** Successful batch scan; the id is single-use and expires after ten minutes. */
|
|
72
|
+
export interface BatchScanResponse {
|
|
73
|
+
readonly ok: true;
|
|
74
|
+
readonly scanId: string;
|
|
75
|
+
readonly sourcePath: string;
|
|
76
|
+
readonly entries: readonly BatchScanEntry[];
|
|
77
|
+
}
|
|
78
|
+
/** Commit a preflight, explicitly naming every destination conflict to replace. */
|
|
79
|
+
export interface BatchCommitRequest {
|
|
80
|
+
readonly scanId: string;
|
|
81
|
+
readonly replace: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
/** Final outcome of one preflight row. */
|
|
84
|
+
export interface BatchCommitEntry {
|
|
85
|
+
readonly name: string;
|
|
86
|
+
readonly status: 'imported' | 'replaced' | 'skipped' | 'error';
|
|
87
|
+
readonly message?: string;
|
|
88
|
+
}
|
|
89
|
+
/** One-time batch commit result. */
|
|
90
|
+
export interface BatchCommitResponse {
|
|
91
|
+
readonly ok: true;
|
|
92
|
+
readonly results: readonly BatchCommitEntry[];
|
|
93
|
+
}
|
|
49
94
|
/** `/skill-importer/list` response. */
|
|
50
95
|
export interface SkillListResponse {
|
|
51
96
|
readonly ok: true;
|