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/README.md +53 -11
- package/README.zh.md +53 -11
- package/lib/client.js +5 -4
- package/lib/index.js +83 -15
- package/lib/types/server.d.ts +9 -8
- package/package.json +6 -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/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
|
+
}
|