dsh-skill-importer 0.1.2 → 0.2.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/lib/index.js CHANGED
@@ -1,6 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { lookup } from "node:dns/promises";
4
+ import { isIP } from "node:net";
2
5
  import { homedir } from "node:os";
3
- import { join } from "node:path";
6
+ import { basename, dirname, extname, isAbsolute, join, relative } from "node:path";
4
7
  //#region src/frontmatter.ts
5
8
  /** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
6
9
  const KEBAB_CASE$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -38,17 +41,18 @@ function parseScalar(raw) {
38
41
  */
39
42
  function parseSkillFile(text) {
40
43
  const frontmatter = {};
41
- if (!text.startsWith("---\n")) return {
44
+ const normalized = text.replace(/\r\n?/g, "\n");
45
+ if (!normalized.startsWith("---\n")) return {
42
46
  frontmatter,
43
47
  body: text
44
48
  };
45
- const end = text.indexOf("\n---", 4);
49
+ const end = normalized.indexOf("\n---", 4);
46
50
  if (end < 0) return {
47
51
  frontmatter,
48
52
  body: text
49
53
  };
50
- const block = text.slice(4, end);
51
- const body = text.slice(end + 4).replace(/^\n/, "");
54
+ const block = normalized.slice(4, end);
55
+ const body = normalized.slice(end + 4).replace(/^\n/, "");
52
56
  for (const line of block.split("\n")) {
53
57
  const colon = line.indexOf(":");
54
58
  if (colon <= 0) continue;
@@ -91,7 +95,17 @@ function parseSkillFile(text) {
91
95
  const MAX_CONTENT_BYTES = 262144;
92
96
  /** Cap for one request body read (JSON overhead above the content cap). */
93
97
  const MAX_BODY_BYTES = 1048576;
98
+ const MAX_BATCH_FILES_PER_SKILL = 2e3;
99
+ const MAX_BATCH_SKILL_BYTES = 10485760;
100
+ const BATCH_SCAN_TTL_MS = 6e5;
94
101
  const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
102
+ const AGENT_SKILL_PARENTS = /* @__PURE__ */ new Set([
103
+ ".agents",
104
+ ".claude",
105
+ ".codex",
106
+ ".dsh"
107
+ ]);
108
+ const MAX_URL_REDIRECTS = 5;
95
109
  /** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
96
110
  function dshHomeDir() {
97
111
  return process.env.DSH_HOME ?? join(homedir(), ".dsh");
@@ -101,21 +115,15 @@ function skillRoot(target, workspacePath) {
101
115
  switch (target) {
102
116
  case "user": return join(dshHomeDir(), "skills");
103
117
  case "project-agents": return join(workspacePath, ".agents", "skills");
104
- case "project-dsh": return join(workspacePath, ".dsh", "skills");
105
118
  }
106
119
  }
107
120
  /** Discovery rank per root (lower wins), mirroring dsh-skill-filesystem. */
108
121
  const ROOT_RANK = {
109
- "project-dsh": 100,
110
122
  "project-agents": 200,
111
123
  user: 400
112
124
  };
113
125
  /** Scan order: project roots first so project skills win duplicate names. */
114
- const SCAN_ORDER = [
115
- "project-dsh",
116
- "project-agents",
117
- "user"
118
- ];
126
+ const SCAN_ORDER = ["project-agents", "user"];
119
127
  /**
120
128
  * Quote one frontmatter string value when plain YAML would mis-parse it:
121
129
  * a value containing `: ` (or any colon), leading/trailing whitespace, a
@@ -240,6 +248,246 @@ function deleteSkillFile(name, source, workspacePath) {
240
248
  }
241
249
  return false;
242
250
  }
251
+ /** Hash names and bytes while refusing symlinks and oversized skill bundles. */
252
+ function sourceStats(sourcePath, kind) {
253
+ const hash = createHash("sha256");
254
+ let fileCount = 0;
255
+ let bytes = 0;
256
+ const visit = (path, relativePath) => {
257
+ const stat = lstatSync(path);
258
+ if (stat.isSymbolicLink()) throw new Error("技能目录不能包含符号链接");
259
+ if (stat.isDirectory()) {
260
+ 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));
261
+ return;
262
+ }
263
+ if (!stat.isFile()) throw new Error("技能目录包含不支持的文件类型");
264
+ fileCount += 1;
265
+ bytes += stat.size;
266
+ if (fileCount > 2e3) throw new Error(`技能文件数量超过 ${MAX_BATCH_FILES_PER_SKILL}`);
267
+ if (bytes > 10485760) throw new Error(`技能目录超过 ${MAX_BATCH_SKILL_BYTES / 1024 / 1024} MB`);
268
+ hash.update(relativePath);
269
+ hash.update("\0");
270
+ hash.update(readFileSync(path));
271
+ hash.update("\0");
272
+ };
273
+ visit(sourcePath, kind === "file" ? basename(sourcePath) : "");
274
+ return {
275
+ fingerprint: hash.digest("hex"),
276
+ fileCount,
277
+ bytes
278
+ };
279
+ }
280
+ function destinationExists(name, target, workspacePath) {
281
+ const root = skillRoot(target, workspacePath ?? "");
282
+ return existsSync(join(root, name)) || existsSync(join(root, `${name}.md`));
283
+ }
284
+ /** Resolve immediate child skill directories and flat Markdown skills. */
285
+ function batchSources(root) {
286
+ const ownSkill = join(root, "SKILL.md");
287
+ if (existsSync(ownSkill)) return [{
288
+ path: root,
289
+ kind: "directory",
290
+ label: basename(root)
291
+ }];
292
+ const sources = [];
293
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
294
+ const path = join(root, entry.name);
295
+ if (entry.isSymbolicLink()) continue;
296
+ if (entry.isDirectory() && existsSync(join(path, "SKILL.md"))) sources.push({
297
+ path,
298
+ kind: "directory",
299
+ label: entry.name
300
+ });
301
+ else if (entry.isFile() && [".md", ".markdown"].includes(extname(entry.name).toLowerCase())) sources.push({
302
+ path,
303
+ kind: "file",
304
+ label: basename(entry.name, extname(entry.name))
305
+ });
306
+ }
307
+ return sources;
308
+ }
309
+ /** Validate a selected skills root without writing anything. */
310
+ function scanBatch(request) {
311
+ if (!isAbsolute(request.sourcePath)) throw new Error("批量导入目录必须是绝对路径");
312
+ if (!existsSync(request.sourcePath) || !statSync(request.sourcePath).isDirectory()) throw new Error("批量导入目录不存在或不可读");
313
+ const sourcePath = realpathSync(request.sourcePath);
314
+ const leaf = basename(sourcePath);
315
+ const parent = basename(dirname(sourcePath));
316
+ const grandparent = basename(dirname(dirname(sourcePath)));
317
+ const isSkillsRoot = leaf === "skills" && AGENT_SKILL_PARENTS.has(parent);
318
+ const isSkillDirectory = parent === "skills" && AGENT_SKILL_PARENTS.has(grandparent);
319
+ if (!isSkillsRoot && !isSkillDirectory) throw new Error("批量导入仅支持 .claude、.codex、.agents 或 .dsh 下的 skills 目录");
320
+ if (request.target !== "user" && request.workspacePath === void 0) throw new Error("项目目标需要 workspacePath(当前工作区路径)");
321
+ const sources = batchSources(sourcePath);
322
+ if (sources.length === 0) throw new Error("所选目录中没有找到可导入的技能");
323
+ if (sources.length > 200) throw new Error(`一次最多扫描 200 个技能`);
324
+ const entries = [];
325
+ const candidates = /* @__PURE__ */ new Map();
326
+ const nameRows = /* @__PURE__ */ new Map();
327
+ for (const [index, source] of sources.entries()) {
328
+ const id = String(index + 1);
329
+ const relativePath = relative(sourcePath, source.path) || ".";
330
+ try {
331
+ const skillFile = source.kind === "directory" ? join(source.path, "SKILL.md") : source.path;
332
+ const text = readFileSync(skillFile, "utf8");
333
+ if (Buffer.byteLength(text, "utf8") > 262144) throw new Error(`SKILL.md 超过 ${MAX_CONTENT_BYTES / 1024} KB`);
334
+ const { frontmatter } = parseSkillFile(text);
335
+ if (frontmatter.name === void 0) throw new Error("frontmatter 缺少 name 字段");
336
+ if (!isValidSkillName(frontmatter.name)) throw new Error("name 必须是 kebab-case(小写字母、数字、短横线)");
337
+ if (frontmatter.description === void 0 || frontmatter.description.trim().length === 0) throw new Error("frontmatter 缺少 description 字段");
338
+ normalizeSkillText(text);
339
+ const stats = sourceStats(source.path, source.kind);
340
+ const warnings = source.label === frontmatter.name ? void 0 : [`目录或文件名“${source.label}”与技能名“${frontmatter.name}”不一致`];
341
+ entries.push({
342
+ id,
343
+ name: frontmatter.name,
344
+ description: frontmatter.description,
345
+ relativePath,
346
+ status: "ready",
347
+ conflict: destinationExists(frontmatter.name, request.target, request.workspacePath),
348
+ ...warnings === void 0 ? {} : { warnings }
349
+ });
350
+ candidates.set(id, {
351
+ id,
352
+ name: frontmatter.name,
353
+ description: frontmatter.description,
354
+ sourcePath: source.path,
355
+ sourceKind: source.kind,
356
+ fingerprint: stats.fingerprint
357
+ });
358
+ const at = entries.length - 1;
359
+ nameRows.set(frontmatter.name, [...nameRows.get(frontmatter.name) ?? [], at]);
360
+ } catch (error) {
361
+ entries.push({
362
+ id,
363
+ name: source.label,
364
+ relativePath,
365
+ status: "error",
366
+ error: error instanceof Error ? error.message : String(error),
367
+ conflict: false
368
+ });
369
+ }
370
+ }
371
+ for (const [name, indexes] of nameRows) {
372
+ if (indexes.length < 2) continue;
373
+ for (const index of indexes) {
374
+ const entry = entries[index];
375
+ if (entry === void 0) continue;
376
+ entries[index] = {
377
+ ...entry,
378
+ status: "error",
379
+ conflict: false,
380
+ error: `批次内存在重复技能名:${name}`
381
+ };
382
+ candidates.delete(entry.id);
383
+ }
384
+ }
385
+ return {
386
+ scanId: randomUUID(),
387
+ sourcePath,
388
+ target: request.target,
389
+ ...request.workspacePath === void 0 ? {} : { workspacePath: request.workspacePath },
390
+ expiresAt: Date.now() + BATCH_SCAN_TTL_MS,
391
+ entries,
392
+ candidates
393
+ };
394
+ }
395
+ /** Copy one validated candidate into a same-root staging directory, then atomically swap it in. */
396
+ function installBatchCandidate(candidate, session, replace) {
397
+ if (sourceStats(candidate.sourcePath, candidate.sourceKind).fingerprint !== candidate.fingerprint) throw new Error("源技能在确认期间发生变化,请重新扫描");
398
+ const root = skillRoot(session.target, session.workspacePath ?? "");
399
+ mkdirSync(root, { recursive: true });
400
+ const finalDirectory = join(root, candidate.name);
401
+ const conflicts = [finalDirectory, join(root, `${candidate.name}.md`)].filter(existsSync);
402
+ if (conflicts.length > 0 && !replace) throw new Error("目标中已存在同名技能");
403
+ const staging = join(root, `.${candidate.name}.import-${randomUUID()}`);
404
+ const backups = [];
405
+ try {
406
+ if (candidate.sourceKind === "directory") {
407
+ cpSync(candidate.sourcePath, staging, {
408
+ recursive: true,
409
+ errorOnExist: true,
410
+ dereference: false
411
+ });
412
+ const skillFile = join(staging, "SKILL.md");
413
+ writeFileSync(skillFile, normalizeSkillText(readFileSync(skillFile, "utf8")), "utf8");
414
+ } else {
415
+ mkdirSync(staging, { recursive: false });
416
+ writeFileSync(join(staging, "SKILL.md"), normalizeSkillText(readFileSync(candidate.sourcePath, "utf8")), "utf8");
417
+ }
418
+ for (const original of conflicts) {
419
+ const backup = join(root, `.${basename(original)}.backup-${randomUUID()}`);
420
+ renameSync(original, backup);
421
+ backups.push({
422
+ original,
423
+ backup
424
+ });
425
+ }
426
+ renameSync(staging, finalDirectory);
427
+ for (const { backup } of backups) rmSync(backup, {
428
+ recursive: true,
429
+ force: true
430
+ });
431
+ return conflicts.length > 0 ? "replaced" : "imported";
432
+ } catch (error) {
433
+ rmSync(staging, {
434
+ recursive: true,
435
+ force: true
436
+ });
437
+ if (existsSync(finalDirectory) && backups.length > 0) rmSync(finalDirectory, {
438
+ recursive: true,
439
+ force: true
440
+ });
441
+ for (const { original, backup } of backups.reverse()) if (existsSync(backup)) renameSync(backup, original);
442
+ throw error;
443
+ }
444
+ }
445
+ /** Commit a valid, unexpired scan once; invalid rows are returned as errors and never written. */
446
+ function commitBatch(session, replaceNames) {
447
+ if (Date.now() > session.expiresAt) throw new Error("批量扫描结果已过期,请重新扫描");
448
+ const results = [];
449
+ for (const entry of session.entries) {
450
+ if (entry.status === "error") {
451
+ results.push({
452
+ name: entry.name,
453
+ status: "error",
454
+ message: entry.error
455
+ });
456
+ continue;
457
+ }
458
+ const candidate = session.candidates.get(entry.id);
459
+ if (candidate === void 0) {
460
+ results.push({
461
+ name: entry.name,
462
+ status: "error",
463
+ message: "扫描记录不完整,请重新扫描"
464
+ });
465
+ continue;
466
+ }
467
+ const conflict = destinationExists(entry.name, session.target, session.workspacePath);
468
+ if (conflict && !replaceNames.has(entry.name)) {
469
+ results.push({
470
+ name: entry.name,
471
+ status: "skipped",
472
+ message: "目标中已存在同名技能,未选择替换"
473
+ });
474
+ continue;
475
+ }
476
+ try {
477
+ results.push({
478
+ name: entry.name,
479
+ status: installBatchCandidate(candidate, session, conflict && replaceNames.has(entry.name))
480
+ });
481
+ } catch (error) {
482
+ results.push({
483
+ name: entry.name,
484
+ status: "error",
485
+ message: error instanceof Error ? error.message : String(error)
486
+ });
487
+ }
488
+ }
489
+ return results;
490
+ }
243
491
  /** Strip HTML down to a plain-Markdown-ish text body (best effort). */
244
492
  function extractHtmlText(html) {
245
493
  const withoutBlocks = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
@@ -254,11 +502,61 @@ function extractHtmlText(html) {
254
502
  * @param url - the source URL.
255
503
  * @returns the text to write as the skill body.
256
504
  */
505
+ function privateIpv4(address) {
506
+ const octets = address.split(".").map(Number);
507
+ if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return true;
508
+ const [a = 0, b = 0, c = 0] = octets;
509
+ return a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 0 || b === 168 || b === 0 && c === 2) || a === 198 && (b === 18 || b === 19 || b === 51 && c === 100) || a === 203 && b === 0 && c === 113;
510
+ }
511
+ /** Whether an address is unsafe for a host-side URL import. */
512
+ function isPrivateAddress(address) {
513
+ const normalized = address.toLowerCase().split("%")[0] ?? "";
514
+ if (isIP(normalized) === 4) return privateIpv4(normalized);
515
+ if (isIP(normalized) !== 6) return true;
516
+ if (normalized.startsWith("::ffff:")) return privateIpv4(normalized.slice(7));
517
+ return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff") || normalized.startsWith("2001:db8:");
518
+ }
519
+ const resolveHost = async (hostname) => lookup(hostname, {
520
+ all: true,
521
+ verbatim: true
522
+ });
523
+ /** Parse and resolve one URL before the host is allowed to request it. */
524
+ async function assertSafeImportUrl(input, resolver = resolveHost) {
525
+ let url;
526
+ try {
527
+ url = new URL(input);
528
+ } catch {
529
+ throw new Error("URL 格式无效");
530
+ }
531
+ if (url.protocol !== "https:") throw new Error("URL 导入仅支持 HTTPS");
532
+ if (url.username.length > 0 || url.password.length > 0) throw new Error("URL 不能包含登录凭据");
533
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
534
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) throw new Error("URL 不能指向本机或私有网络");
535
+ const addresses = isIP(hostname) === 0 ? await resolver(hostname) : [{ address: hostname }];
536
+ if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) throw new Error("URL 不能指向本机、私有网络或保留地址");
537
+ return url;
538
+ }
257
539
  async function fetchUrlContent(url) {
258
- const response = await fetch(url, {
259
- redirect: "follow",
260
- signal: AbortSignal.timeout(15e3)
261
- });
540
+ let current = await assertSafeImportUrl(url);
541
+ let response;
542
+ for (let redirects = 0; redirects <= MAX_URL_REDIRECTS; redirects += 1) {
543
+ response = await fetch(current, {
544
+ redirect: "manual",
545
+ signal: AbortSignal.timeout(15e3)
546
+ });
547
+ if (![
548
+ 301,
549
+ 302,
550
+ 303,
551
+ 307,
552
+ 308
553
+ ].includes(response.status)) break;
554
+ if (redirects === MAX_URL_REDIRECTS) throw new Error(`重定向次数超过 ${MAX_URL_REDIRECTS}`);
555
+ const location = response.headers.get("location");
556
+ if (location === null) throw new Error("重定向响应缺少 Location");
557
+ current = await assertSafeImportUrl(new URL(location, current).href);
558
+ }
559
+ if (response === void 0) throw new Error("抓取失败");
262
560
  if (!response.ok) throw new Error(`抓取失败:HTTP ${response.status}`);
263
561
  const type = response.headers.get("content-type") ?? "";
264
562
  const text = await response.text();
@@ -302,11 +600,12 @@ function readJsonBody(req, limit = MAX_BODY_BYTES) {
302
600
  * Origin fence: the routes are served on the harness's loopback-only web
303
601
  * server, so only the browser page itself (or a local curl) reaches them.
304
602
  * A cross-origin page (any other website) is refused. Requests without an
305
- * Origin header (curl, same-origin GET) pass.
603
+ * Origin header is rejected: all state-changing browser requests include it,
604
+ * while accepting an absent header would let non-browser clients bypass the fence.
306
605
  */
307
606
  function originAllowed(req) {
308
607
  const origin = req.headers.origin;
309
- if (origin === void 0) return true;
608
+ if (origin === void 0) return false;
310
609
  try {
311
610
  const hostname = new URL(origin).hostname;
312
611
  return hostname === "127.0.0.1" || hostname === "localhost";
@@ -338,7 +637,9 @@ const ROUTES = {
338
637
  list: "/skill-importer/list",
339
638
  import: "/skill-importer/import",
340
639
  importUrl: "/skill-importer/import-url",
341
- delete: "/skill-importer/delete"
640
+ delete: "/skill-importer/delete",
641
+ batchScan: "/skill-importer/batch/scan",
642
+ batchCommit: "/skill-importer/batch/commit"
342
643
  };
343
644
  /** Required services: the harness web server's route registry and the workspace registry. */
344
645
  const inject = ["webServer", "workspaceRegistry"];
@@ -354,6 +655,11 @@ const handle = (fn) => (req, res) => {
354
655
  */
355
656
  function apply(ctx) {
356
657
  const projectPaths = () => ctx.workspaceRegistry.list().map((workspace) => workspace.path);
658
+ const batchScans = /* @__PURE__ */ new Map();
659
+ const pruneBatchScans = () => {
660
+ const now = Date.now();
661
+ for (const [id, session] of batchScans) if (session.expiresAt <= now) batchScans.delete(id);
662
+ };
357
663
  const routes = [
358
664
  {
359
665
  kind: "exact",
@@ -379,7 +685,7 @@ function apply(ctx) {
379
685
  return;
380
686
  }
381
687
  const body = await readJsonBody(req);
382
- if (typeof body.name !== "string" || typeof body.content !== "string" || body.target !== "user" && body.target !== "project-agents" && body.target !== "project-dsh") {
688
+ if (typeof body.name !== "string" || typeof body.content !== "string" || body.target !== "user" && body.target !== "project-agents") {
383
689
  sendError(res, 400, "请求缺少 name / content / target 字段");
384
690
  return;
385
691
  }
@@ -404,7 +710,7 @@ function apply(ctx) {
404
710
  return;
405
711
  }
406
712
  const body = await readJsonBody(req);
407
- if (typeof body.name !== "string" || typeof body.url !== "string" || body.target !== "user" && body.target !== "project-agents" && body.target !== "project-dsh") {
713
+ if (typeof body.name !== "string" || typeof body.url !== "string" || body.target !== "user" && body.target !== "project-agents") {
408
714
  sendError(res, 400, "请求缺少 name / url / target 字段");
409
715
  return;
410
716
  }
@@ -429,7 +735,7 @@ function apply(ctx) {
429
735
  return;
430
736
  }
431
737
  const body = await readJsonBody(req);
432
- if (typeof body.name !== "string" || body.source !== "user" && body.source !== "project-agents" && body.source !== "project-dsh") {
738
+ if (typeof body.name !== "string" || body.source !== "user" && body.source !== "project-agents") {
433
739
  sendError(res, 400, "请求缺少 name / source 字段");
434
740
  return;
435
741
  }
@@ -439,6 +745,62 @@ function apply(ctx) {
439
745
  removed: deleteSkillFile(body.name, body.source, workspacePath)
440
746
  });
441
747
  })
748
+ },
749
+ {
750
+ kind: "exact",
751
+ path: ROUTES.batchScan,
752
+ handler: handle(async (req, res) => {
753
+ if (!originAllowed(req)) {
754
+ sendError(res, 403, "origin 不被允许");
755
+ return;
756
+ }
757
+ const body = await readJsonBody(req);
758
+ if (typeof body.sourcePath !== "string" || body.target !== "user" && body.target !== "project-agents") {
759
+ sendError(res, 400, "请求缺少 sourcePath / target 字段");
760
+ return;
761
+ }
762
+ const workspacePath = requireWorkspace(body.target, body.workspacePath);
763
+ pruneBatchScans();
764
+ const session = scanBatch({
765
+ sourcePath: body.sourcePath,
766
+ target: body.target,
767
+ workspacePath
768
+ });
769
+ batchScans.set(session.scanId, session);
770
+ sendJson(res, 200, {
771
+ ok: true,
772
+ scanId: session.scanId,
773
+ sourcePath: session.sourcePath,
774
+ entries: session.entries
775
+ });
776
+ })
777
+ },
778
+ {
779
+ kind: "exact",
780
+ path: ROUTES.batchCommit,
781
+ handler: handle(async (req, res) => {
782
+ if (!originAllowed(req)) {
783
+ sendError(res, 403, "origin 不被允许");
784
+ return;
785
+ }
786
+ const body = await readJsonBody(req);
787
+ if (typeof body.scanId !== "string" || !Array.isArray(body.replace) || !body.replace.every((name) => typeof name === "string")) {
788
+ sendError(res, 400, "请求缺少 scanId / replace 字段");
789
+ return;
790
+ }
791
+ pruneBatchScans();
792
+ const session = batchScans.get(body.scanId);
793
+ if (session === void 0) {
794
+ sendError(res, 410, "批量扫描结果不存在或已过期,请重新扫描");
795
+ return;
796
+ }
797
+ batchScans.delete(body.scanId);
798
+ const allowedNames = new Set(session.entries.filter((entry) => entry.status === "ready" && entry.conflict).map((entry) => entry.name));
799
+ sendJson(res, 200, {
800
+ ok: true,
801
+ results: commitBatch(session, new Set(body.replace.filter((name) => allowedNames.has(name))))
802
+ });
803
+ })
442
804
  }
443
805
  ];
444
806
  /**
@@ -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;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host (Node) half of the dsh-skill-importer plugin.
3
3
  *
4
- * Registers four `/skill-importer/*` HTTP routes on the harness's own web
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[];