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.
@@ -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,13 +58,36 @@ 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;
56
- /**
57
- * Fetch a URL's content for import. Markdown/plain responses pass through
58
- * verbatim; HTML is roughly extracted to text (the import UI recommends
59
- * `.md` sources).
60
- * @param url - the source URL.
61
- * @returns the text to write as the skill body.
62
- */
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[];
84
+ /** Whether an address is unsafe for a host-side URL import. */
85
+ export declare function isPrivateAddress(address: string): boolean;
86
+ type ResolveHost = (hostname: string) => Promise<readonly {
87
+ readonly address: string;
88
+ }[]>;
89
+ /** Parse and resolve one URL before the host is allowed to request it. */
90
+ export declare function assertSafeImportUrl(input: string, resolver?: ResolveHost): Promise<URL>;
63
91
  export declare function fetchUrlContent(url: string): Promise<string>;
64
92
  /**
65
93
  * Resolve one import request into a written file path. Shared by the file
@@ -74,10 +102,12 @@ export declare function readJsonBody(req: IncomingMessage, limit?: number): Prom
74
102
  * Origin fence: the routes are served on the harness's loopback-only web
75
103
  * server, so only the browser page itself (or a local curl) reaches them.
76
104
  * A cross-origin page (any other website) is refused. Requests without an
77
- * Origin header (curl, same-origin GET) pass.
105
+ * Origin header is rejected: all state-changing browser requests include it,
106
+ * while accepting an absent header would let non-browser clients bypass the fence.
78
107
  */
79
108
  export declare function originAllowed(req: IncomingMessage): boolean;
80
109
  /** Send one JSON response. */
81
110
  export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
82
111
  /** Send a JSON error response with the given status. */
83
112
  export declare function sendError(res: ServerResponse, status: number, error: string): void;
113
+ 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-importer",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Web UI plugin for DeepSeek Harness: import skills from local Markdown files or URLs into the skill roots the harness discovers automatically",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -36,10 +36,15 @@
36
36
  "lib/index.js",
37
37
  "lib/client.js",
38
38
  "lib/types/**/*.d.ts",
39
- "cordis.patch.yml"
39
+ "cordis.patch.yml",
40
+ "src/frontmatter.ts",
41
+ "src/server.ts",
42
+ "src/types.ts",
43
+ "tests/**/*.mjs"
40
44
  ],
41
45
  "scripts": {
42
46
  "build": "rm -rf lib && tsc -p tsconfig.json && tsdown",
47
+ "test:batch": "node --experimental-strip-types tests/batch-import.test.mjs",
43
48
  "watch": "tsdown --watch"
44
49
  },
45
50
  "license": "MIT",
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Skill Markdown frontmatter parsing and validation (pure, browser-safe).
3
+ *
4
+ * Mirrors the subset of `dsh-skill-filesystem`'s frontmatter contract the
5
+ * import UI needs for preview and pre-flight validation. The authoritative
6
+ * parse remains the host provider's; this parser only previews and catches
7
+ * obvious mistakes before the import instruction is sent.
8
+ */
9
+
10
+ /** Frontmatter fields the import UI understands. */
11
+ export interface SkillFrontmatter {
12
+ /** Kebab-case skill name (required for a valid skill). */
13
+ name?: string
14
+ /** One-line routing description (required for a valid skill). */
15
+ description?: string
16
+ /** Optional routing guidance. */
17
+ whenToUse?: string
18
+ /** `disable-model-invocation: true` keeps the skill out of model catalogs. */
19
+ disableModelInvocation?: boolean
20
+ /** `user-invocable: false` keeps the skill out of the `/` menu. */
21
+ userInvocable?: boolean
22
+ }
23
+
24
+ /** Parsed Markdown file: frontmatter plus the body after it. */
25
+ export interface ParsedSkillFile {
26
+ /** Parsed frontmatter values (empty when the file has no frontmatter block). */
27
+ readonly frontmatter: SkillFrontmatter
28
+ /** Markdown body after the closing `---` (empty when absent). */
29
+ readonly body: string
30
+ }
31
+
32
+ /** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
33
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
34
+
35
+ /** True when the name satisfies the harness skill-name rule. */
36
+ export function isValidSkillName(name: string): boolean {
37
+ return KEBAB_CASE.test(name)
38
+ }
39
+
40
+ const BOOLEAN_TRUE = new Set(['true', 'yes', 'on', '1'])
41
+ const BOOLEAN_FALSE = new Set(['false', 'no', 'off', '0'])
42
+
43
+ /** Parse one frontmatter scalar: quoted strings, booleans, or plain text. */
44
+ function parseScalar(raw: string): string | boolean | undefined {
45
+ const value = raw.trim()
46
+ if (value.length === 0) return undefined
47
+ const lower = value.toLowerCase()
48
+ if (BOOLEAN_TRUE.has(lower)) return true
49
+ if (BOOLEAN_FALSE.has(lower)) return false
50
+ if (
51
+ (value.startsWith('"') && value.endsWith('"') && value.length >= 2)
52
+ || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
53
+ ) {
54
+ return value.slice(1, -1)
55
+ }
56
+ return value
57
+ }
58
+
59
+ /**
60
+ * Parse a skill Markdown file's frontmatter block.
61
+ * @param text - full file text.
62
+ * @returns parsed frontmatter and body; a file without a leading `---` block
63
+ * yields an empty frontmatter with the whole text as body.
64
+ */
65
+ export function parseSkillFile(text: string): ParsedSkillFile {
66
+ const frontmatter: SkillFrontmatter = {}
67
+ // Normalize platform line endings before locating and parsing the YAML
68
+ // fence. This accepts Windows CRLF and legacy CR files while keeping the
69
+ // canonical output produced by normalizeSkillText consistently LF-based.
70
+ const normalized = text.replace(/\r\n?/g, '\n')
71
+ if (!normalized.startsWith('---\n')) return { frontmatter, body: text }
72
+ const end = normalized.indexOf('\n---', 4)
73
+ if (end < 0) return { frontmatter, body: text }
74
+ const block = normalized.slice(4, end)
75
+ const body = normalized.slice(end + 4).replace(/^\n/, '')
76
+ for (const line of block.split('\n')) {
77
+ const colon = line.indexOf(':')
78
+ if (colon <= 0) continue
79
+ const key = line.slice(0, colon).trim()
80
+ const value = parseScalar(line.slice(colon + 1))
81
+ if (value === undefined) continue
82
+ switch (key) {
83
+ case 'name':
84
+ if (typeof value === 'string') frontmatter.name = value
85
+ break
86
+ case 'description':
87
+ if (typeof value === 'string') frontmatter.description = value
88
+ break
89
+ case 'whenToUse':
90
+ if (typeof value === 'string') frontmatter.whenToUse = value
91
+ break
92
+ case 'disable-model-invocation':
93
+ if (typeof value === 'boolean') frontmatter.disableModelInvocation = value
94
+ break
95
+ case 'user-invocable':
96
+ if (typeof value === 'boolean') frontmatter.userInvocable = value
97
+ break
98
+ default:
99
+ break
100
+ }
101
+ }
102
+ return { frontmatter, body }
103
+ }
104
+
105
+ /** Why a parsed file cannot be imported as a skill (or undefined when it can). */
106
+ export function validateSkillFile(text: string): string | undefined {
107
+ const { frontmatter } = parseSkillFile(text)
108
+ if (frontmatter.name === undefined || frontmatter.name.length === 0) return 'frontmatter 缺少 name 字段'
109
+ if (!isValidSkillName(frontmatter.name)) return 'name 必须是 kebab-case(小写字母、数字、短横线)'
110
+ if (frontmatter.description === undefined || frontmatter.description.length === 0) return 'frontmatter 缺少 description 字段'
111
+ return undefined
112
+ }