dsh-skill-importer 0.2.0 → 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/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared wire types between the host half (HTTP routes) and the client half
3
+ * (fetch calls). Pure types — no runtime code, no node imports — so the
4
+ * browser bundle can reference them safely.
5
+ */
6
+
7
+ /** Where the imported skill file should land. */
8
+ export type ImportTarget = 'user' | 'project-agents'
9
+
10
+ /** One installed skill row served by `/skill-importer/list`. */
11
+ export interface SkillListEntry {
12
+ /** Kebab-case skill name (frontmatter `name`). */
13
+ readonly name: string
14
+ /** One-line routing description (frontmatter `description`). */
15
+ readonly description: string
16
+ /** Optional routing guidance (frontmatter `whenToUse`). */
17
+ readonly whenToUse?: string
18
+ /** False marks a user-only skill (`disable-model-invocation`). */
19
+ readonly modelInvocable: boolean
20
+ /** False marks a model-only skill (`user-invocable: false`). */
21
+ readonly userInvocable: boolean
22
+ /** Skill root (`'project-agents'` or `'user'`). */
23
+ readonly source: ImportTarget
24
+ }
25
+
26
+ /** File-import request body. */
27
+ export interface ImportRequest {
28
+ readonly name: string
29
+ readonly target: ImportTarget
30
+ /** Full Markdown skill file text (frontmatter included). */
31
+ readonly content: string
32
+ /** Canonical workspace path for project targets (host-validated against the registry). */
33
+ readonly workspacePath?: string
34
+ }
35
+
36
+ /** Delete-request body. */
37
+ export interface DeleteRequest {
38
+ readonly name: string
39
+ /** Which root the copy lives in (`'project-agents'` or `'user'`). */
40
+ readonly source: ImportTarget
41
+ /** Canonical workspace path for project sources (host-validated). */
42
+ readonly workspacePath?: string
43
+ }
44
+
45
+ /** URL-import request body. */
46
+ export interface ImportUrlRequest {
47
+ readonly name: string
48
+ readonly target: ImportTarget
49
+ /** Source URL the host fetches (`.md` preferred; HTML is roughly extracted). */
50
+ readonly url: string
51
+ /** Canonical workspace path for project targets (host-validated against the registry). */
52
+ readonly workspacePath?: string
53
+ }
54
+
55
+ /** Read-only preflight request for one local skills directory. */
56
+ export interface BatchScanRequest {
57
+ readonly sourcePath: string
58
+ readonly target: ImportTarget
59
+ readonly workspacePath?: string
60
+ }
61
+
62
+ /** One source entry reported by batch preflight. */
63
+ export interface BatchScanEntry {
64
+ /** Stable row id within this one scan. */
65
+ readonly id: string
66
+ /** Frontmatter name when readable, otherwise a best-effort source label. */
67
+ readonly name: string
68
+ readonly description?: string
69
+ /** Path relative to the selected source directory. */
70
+ readonly relativePath: string
71
+ /** Error rows can never be selected for commit. */
72
+ readonly status: 'ready' | 'error'
73
+ readonly error?: string
74
+ readonly warnings?: readonly string[]
75
+ /** The selected destination already contains this skill name. */
76
+ readonly conflict: boolean
77
+ }
78
+
79
+ /** Successful batch scan; the id is single-use and expires after ten minutes. */
80
+ export interface BatchScanResponse {
81
+ readonly ok: true
82
+ readonly scanId: string
83
+ readonly sourcePath: string
84
+ readonly entries: readonly BatchScanEntry[]
85
+ }
86
+
87
+ /** Commit a preflight, explicitly naming every destination conflict to replace. */
88
+ export interface BatchCommitRequest {
89
+ readonly scanId: string
90
+ readonly replace: readonly string[]
91
+ }
92
+
93
+ /** Final outcome of one preflight row. */
94
+ export interface BatchCommitEntry {
95
+ readonly name: string
96
+ readonly status: 'imported' | 'replaced' | 'skipped' | 'error'
97
+ readonly message?: string
98
+ }
99
+
100
+ /** One-time batch commit result. */
101
+ export interface BatchCommitResponse {
102
+ readonly ok: true
103
+ readonly results: readonly BatchCommitEntry[]
104
+ }
105
+
106
+ /** `/skill-importer/list` response. */
107
+ export interface SkillListResponse {
108
+ readonly ok: true
109
+ readonly skills: readonly SkillListEntry[]
110
+ }
111
+
112
+ /** Success response of an import. */
113
+ export interface ImportResponse {
114
+ readonly ok: true
115
+ /** Absolute path of the written SKILL.md. */
116
+ readonly path: string
117
+ }
118
+
119
+ /** Error response of any route. */
120
+ export interface ErrorResponse {
121
+ readonly ok: false
122
+ readonly error: string
123
+ }
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { parseSkillFile, validateSkillFile } from '../src/frontmatter.ts'
6
+ import { assertSafeImportUrl, commitBatch, isPrivateAddress, originAllowed, scanBatch } from '../src/server.ts'
7
+
8
+ const sandbox = mkdtempSync(join(tmpdir(), 'dsh-batch-test-'))
9
+ process.env.DSH_HOME = join(sandbox, 'home')
10
+
11
+ const skill = (name, description = `Description for ${name}`) => `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`
12
+
13
+ const crlfSkill = skill('windows-crlf').replace(/\n/g, '\r\n')
14
+ assert.equal(parseSkillFile(crlfSkill).frontmatter.name, 'windows-crlf')
15
+ assert.equal(validateSkillFile(crlfSkill), undefined)
16
+ assert.equal(parseSkillFile(skill('legacy-cr').replace(/\n/g, '\r')).frontmatter.name, 'legacy-cr')
17
+
18
+ try {
19
+ const source = join(sandbox, '.claude', 'skills')
20
+ mkdirSync(source, { recursive: true })
21
+
22
+ // A complete directory skill proves resources survive the migration.
23
+ mkdirSync(join(source, 'alpha', 'scripts'), { recursive: true })
24
+ writeFileSync(join(source, 'alpha', 'SKILL.md'), skill('alpha'))
25
+ writeFileSync(join(source, 'alpha', 'scripts', 'run.sh'), 'echo alpha\n')
26
+
27
+ // Windows CRLF skills must pass the same batch preflight and normalize to LF.
28
+ mkdirSync(join(source, 'windows-crlf'), { recursive: true })
29
+ writeFileSync(join(source, 'windows-crlf', 'SKILL.md'), crlfSkill)
30
+
31
+ // Invalid metadata must be reported but never written.
32
+ mkdirSync(join(source, 'invalid'), { recursive: true })
33
+ writeFileSync(join(source, 'invalid', 'SKILL.md'), '---\nname: invalid\n---\n')
34
+
35
+ // Ambiguous names inside one batch refuse every copy.
36
+ for (const folder of ['dup-a', 'dup-b']) {
37
+ mkdirSync(join(source, folder), { recursive: true })
38
+ writeFileSync(join(source, folder, 'SKILL.md'), skill('duplicate'))
39
+ }
40
+
41
+ // Existing destination content is only replaced after explicit confirmation.
42
+ const existing = join(process.env.DSH_HOME, 'skills', 'alpha')
43
+ mkdirSync(existing, { recursive: true })
44
+ writeFileSync(join(existing, 'SKILL.md'), skill('alpha', 'old'))
45
+
46
+ const scan = scanBatch({ sourcePath: source, target: 'user' })
47
+ assert.equal(scan.entries.find(row => row.name === 'alpha')?.conflict, true)
48
+ assert.equal(scan.entries.find(row => row.name === 'windows-crlf')?.status, 'ready')
49
+ assert.equal(scan.entries.find(row => row.name === 'invalid')?.status, 'error')
50
+ assert.equal(scan.entries.filter(row => row.name === 'duplicate' && row.status === 'error').length, 2)
51
+
52
+ const results = commitBatch(scan, new Set(['alpha']))
53
+ assert.equal(results.find(row => row.name === 'alpha')?.status, 'replaced')
54
+ assert.equal(results.find(row => row.name === 'windows-crlf')?.status, 'imported')
55
+ assert.equal(results.filter(row => row.name === 'duplicate' && row.status === 'error').length, 2)
56
+ assert.equal(existsSync(join(process.env.DSH_HOME, 'skills', 'alpha', 'scripts', 'run.sh')), true)
57
+ assert.match(readFileSync(join(process.env.DSH_HOME, 'skills', 'alpha', 'SKILL.md'), 'utf8'), /Description for alpha/)
58
+ assert.doesNotMatch(readFileSync(join(process.env.DSH_HOME, 'skills', 'windows-crlf', 'SKILL.md'), 'utf8'), /\r/)
59
+ assert.equal(existsSync(join(process.env.DSH_HOME, 'skills', 'invalid')), false)
60
+ assert.equal(existsSync(join(process.env.DSH_HOME, 'skills', 'duplicate')), false)
61
+
62
+ // A changed source invalidates the preflight and leaves no partial destination.
63
+ const changedSource = join(sandbox, '.codex', 'skills')
64
+ mkdirSync(join(changedSource, 'beta'), { recursive: true })
65
+ writeFileSync(join(changedSource, 'beta', 'SKILL.md'), skill('beta'))
66
+ const changedScan = scanBatch({ sourcePath: changedSource, target: 'user' })
67
+ writeFileSync(join(changedSource, 'beta', 'SKILL.md'), skill('beta', 'changed after scan'))
68
+ const changedResults = commitBatch(changedScan, new Set())
69
+ assert.equal(changedResults[0]?.status, 'error')
70
+ assert.match(changedResults[0]?.message ?? '', /发生变化/)
71
+ assert.equal(existsSync(join(process.env.DSH_HOME, 'skills', 'beta')), false)
72
+
73
+ const empty = join(sandbox, '.agents', 'skills')
74
+ mkdirSync(empty, { recursive: true })
75
+ assert.throws(() => scanBatch({ sourcePath: empty, target: 'user' }), /没有找到/)
76
+
77
+ const arbitrary = join(sandbox, 'arbitrary')
78
+ mkdirSync(arbitrary)
79
+ assert.throws(() => scanBatch({ sourcePath: arbitrary, target: 'user' }), /仅支持/)
80
+
81
+ assert.equal(originAllowed({ headers: {} }), false)
82
+ assert.equal(originAllowed({ headers: { origin: 'https://evil.example' } }), false)
83
+ assert.equal(originAllowed({ headers: { origin: 'http://127.0.0.1:2026' } }), true)
84
+ assert.equal(isPrivateAddress('127.0.0.1'), true)
85
+ assert.equal(isPrivateAddress('169.254.169.254'), true)
86
+ assert.equal(isPrivateAddress('10.0.0.1'), true)
87
+ assert.equal(isPrivateAddress('::1'), true)
88
+ assert.equal(isPrivateAddress('8.8.8.8'), false)
89
+ await assert.rejects(assertSafeImportUrl('http://example.com/skill.md'), /仅支持 HTTPS/)
90
+ await assert.rejects(assertSafeImportUrl('https://localhost/skill.md'), /私有网络/)
91
+ await assert.rejects(assertSafeImportUrl('https://[::1]/skill.md'), /私有网络|保留地址/)
92
+ await assert.rejects(
93
+ assertSafeImportUrl('https://internal.example/skill.md', async () => [{ address: '10.0.0.2' }]),
94
+ /私有网络/,
95
+ )
96
+ const publicUrl = await assertSafeImportUrl(
97
+ 'https://skills.example/skill.md',
98
+ async () => [{ address: '93.184.216.34' }],
99
+ )
100
+ assert.equal(publicUrl.hostname, 'skills.example')
101
+ console.log('batch import test: OK')
102
+ } finally {
103
+ rmSync(sandbox, { recursive: true, force: true })
104
+ }