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/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" && body.target !== "project-dsh") {
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" && body.target !== "project-dsh") {
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" && body.source !== "project-dsh") {
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;
@@ -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[];
@@ -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 {};
@@ -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' | 'project-dsh';
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
- /** Winning discovery root ('project-dsh' | 'project-agents' | 'user'). */
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 ('user' | 'project-agents' | 'project-dsh'). */
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;