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/README.md +62 -14
- package/README.zh.md +63 -15
- package/lib/client.js +1147 -308
- package/lib/index.js +385 -23
- 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 +39 -9
- package/lib/types/types.d.ts +48 -3
- package/package.json +7 -2
- package/src/frontmatter.ts +112 -0
- package/src/server.ts +605 -0
- package/src/types.ts +123 -0
- package/tests/batch-import.test.mjs +104 -0
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,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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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 {};
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-importer",
|
|
3
|
-
"version": "0.1
|
|
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
|
+
}
|