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/src/server.ts ADDED
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Host-side skill importer logic: direct filesystem writes, skill-root
3
+ * scanning, and URL fetching. Pure Node — no Cordis imports — so the route
4
+ * handlers stay unit-testable and the plugin body is only wiring.
5
+ *
6
+ * This is the "direct write" path: the host process owns the filesystem
7
+ * (no agent sandbox, no approval), so an import lands the file immediately
8
+ * and the skill-filesystem watcher discovers it in place.
9
+ */
10
+
11
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
+ import { createHash, randomUUID } from 'node:crypto'
13
+ import { lookup } from 'node:dns/promises'
14
+ import { isIP } from 'node:net'
15
+ import { homedir } from 'node:os'
16
+ import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path'
17
+ import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import { isValidSkillName, parseSkillFile } from './frontmatter.ts'
19
+ import type {
20
+ BatchCommitEntry, BatchScanEntry, BatchScanRequest,
21
+ ImportRequest, ImportTarget, ImportUrlRequest, SkillListEntry,
22
+ } from './types.ts'
23
+
24
+ /** Hard cap for one imported skill body (matches the client preview limit). */
25
+ export const MAX_CONTENT_BYTES = 256 * 1024
26
+
27
+ /** Cap for one request body read (JSON overhead above the content cap). */
28
+ export const MAX_BODY_BYTES = 1024 * 1024
29
+
30
+ /** Batch safety bounds: resources are copied, but one selection stays finite. */
31
+ export const MAX_BATCH_SKILLS = 200
32
+ export const MAX_BATCH_FILES_PER_SKILL = 2_000
33
+ export const MAX_BATCH_SKILL_BYTES = 10 * 1024 * 1024
34
+ export const BATCH_SCAN_TTL_MS = 10 * 60 * 1000
35
+
36
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
37
+ const AGENT_SKILL_PARENTS = new Set(['.agents', '.claude', '.codex', '.dsh'])
38
+ const MAX_URL_REDIRECTS = 5
39
+
40
+ /** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
41
+ export function dshHomeDir(): string {
42
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
43
+ }
44
+
45
+ /** Absolute skill root for one target under one workspace. */
46
+ export function skillRoot(target: ImportTarget, workspacePath: string): string {
47
+ switch (target) {
48
+ case 'user':
49
+ return join(dshHomeDir(), 'skills')
50
+ case 'project-agents':
51
+ return join(workspacePath, '.agents', 'skills')
52
+ }
53
+ }
54
+
55
+ /** Discovery rank per root (lower wins), mirroring dsh-skill-filesystem. */
56
+ const ROOT_RANK: Record<ImportTarget, number> = { 'project-agents': 200, user: 400 }
57
+
58
+ /** Scan order: project roots first so project skills win duplicate names. */
59
+ const SCAN_ORDER: readonly ImportTarget[] = ['project-agents', 'user']
60
+
61
+ /**
62
+ * Quote one frontmatter string value when plain YAML would mis-parse it:
63
+ * a value containing `: ` (or any colon), leading/trailing whitespace, a
64
+ * leading YAML special character, or ` #` would fail or change meaning
65
+ * under the strict YAML parser `dsh-skill-filesystem` uses — such a file is
66
+ * silently skipped by discovery. `JSON.stringify` emits a YAML-compatible
67
+ * double-quoted scalar.
68
+ */
69
+ function yamlScalar(value: string): string {
70
+ if (value.length === 0
71
+ || /^[\s]/.test(value)
72
+ || /[\s]$/.test(value)
73
+ || /:/.test(value)
74
+ || /#/.test(value)
75
+ || /^[!&*{}\[\],|>'"%@`?]/.test(value)) {
76
+ return JSON.stringify(value)
77
+ }
78
+ return value
79
+ }
80
+
81
+ /**
82
+ * Rebuild a skill file's frontmatter in the canonical, strictly-YAML-valid
83
+ * form the harness's provider parses. Unknown keys are dropped (the harness
84
+ * only consumes name/description/whenToUse and the two invocation flags);
85
+ * the body is preserved verbatim (trimmed).
86
+ */
87
+ export function normalizeSkillText(text: string): string {
88
+ const { frontmatter, body } = parseSkillFile(text)
89
+ if (frontmatter.name === undefined || frontmatter.description === undefined) {
90
+ throw new Error('frontmatter 缺少 name 或 description 字段')
91
+ }
92
+ const lines = ['---']
93
+ lines.push(`name: ${frontmatter.name}`)
94
+ lines.push(`description: ${yamlScalar(frontmatter.description)}`)
95
+ if (frontmatter.whenToUse !== undefined) lines.push(`whenToUse: ${yamlScalar(frontmatter.whenToUse)}`)
96
+ if (frontmatter.disableModelInvocation !== undefined) lines.push(`disable-model-invocation: ${frontmatter.disableModelInvocation}`)
97
+ if (frontmatter.userInvocable !== undefined) lines.push(`user-invocable: ${frontmatter.userInvocable}`)
98
+ lines.push('---')
99
+ const normalizedBody = body.trim()
100
+ return lines.join('\n') + (normalizedBody.length > 0 ? `\n\n${normalizedBody}\n` : '\n')
101
+ }
102
+
103
+ /**
104
+ * Write one skill file atomically: `<root>/<name>/SKILL.md`, created via a
105
+ * same-directory temp file plus rename so a crash never leaves a torn file.
106
+ * The content's frontmatter is normalized first so the harness's strict YAML
107
+ * discovery always finds the skill.
108
+ * @param name - kebab-case skill name (validated).
109
+ * @param target - which skill root to write into.
110
+ * @param content - full Markdown text (frontmatter included).
111
+ * @param workspacePath - canonical workspace path; required for project targets.
112
+ * @returns the absolute path of the written file.
113
+ */
114
+ export function writeSkillFile(name: string, target: ImportTarget, content: string, workspacePath?: string): string {
115
+ if (!KEBAB_CASE.test(name)) throw new Error('技能名必须是 kebab-case(小写字母、数字、短横线)')
116
+ if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) throw new Error(`内容超过 ${MAX_CONTENT_BYTES / 1024} KB`)
117
+ if (target !== 'user' && workspacePath === undefined) {
118
+ throw new Error('项目目标需要 workspacePath(当前工作区路径)')
119
+ }
120
+ const normalized = normalizeSkillText(content)
121
+ const directory = join(skillRoot(target, workspacePath ?? ''), name)
122
+ mkdirSync(directory, { recursive: true })
123
+ const file = join(directory, 'SKILL.md')
124
+ const temporary = `${file}.tmp`
125
+ writeFileSync(temporary, normalized, 'utf8')
126
+ renameSync(temporary, file)
127
+ return file
128
+ }
129
+
130
+ /** Scan one skill root and fold its skills into the name-keyed map (rank-aware). */
131
+ function scanRoot(root: string, source: ImportTarget, out: Map<string, SkillListEntry>): void {
132
+ if (!existsSync(root)) return
133
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
134
+ const entryPath = join(root, entry.name)
135
+ let file: string | undefined
136
+ if (entry.isDirectory()) {
137
+ const candidate = join(entryPath, 'SKILL.md')
138
+ if (existsSync(candidate)) file = candidate
139
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
140
+ file = entryPath
141
+ }
142
+ if (file === undefined) continue
143
+ const { frontmatter } = parseSkillFile(readFileSync(file, 'utf8'))
144
+ if (frontmatter.name === undefined || frontmatter.description === undefined) continue
145
+ if (!isValidSkillName(frontmatter.name)) continue
146
+ const existing = out.get(frontmatter.name)
147
+ if (existing !== undefined && ROOT_RANK[existing.source] <= ROOT_RANK[source]) continue
148
+ out.set(frontmatter.name, {
149
+ name: frontmatter.name,
150
+ description: frontmatter.description,
151
+ ...(frontmatter.whenToUse !== undefined ? { whenToUse: frontmatter.whenToUse } : {}),
152
+ modelInvocable: frontmatter.disableModelInvocation !== true,
153
+ userInvocable: frontmatter.userInvocable !== false,
154
+ source,
155
+ })
156
+ }
157
+ }
158
+
159
+ /**
160
+ * List every installed skill across every registered workspace's project
161
+ * roots plus the user root. No rank deduplication: the management surface
162
+ * shows every location's copy (the framework's own catalog still applies
163
+ * rank at discovery time). Display order groups by source, then name.
164
+ * @param workspacePaths - canonical paths of the registered workspaces.
165
+ */
166
+ export function listSkills(workspacePaths: readonly string[]): SkillListEntry[] {
167
+ const rows: SkillListEntry[] = []
168
+ for (const target of SCAN_ORDER) {
169
+ const out = new Map<string, SkillListEntry>()
170
+ for (const path of target === 'user' ? [dshHomeDir()] : workspacePaths) {
171
+ scanRoot(skillRoot(target, path), target, out)
172
+ }
173
+ rows.push(...out.values())
174
+ }
175
+ return rows.sort((a, b) => a.name.localeCompare(b.name))
176
+ }
177
+
178
+ /**
179
+ * Delete one installed skill (its whole bundle directory `<root>/<name>/`,
180
+ * or the flat `<root>/<name>.md`), scoped to the skill roots only.
181
+ * @param name - kebab-case skill name (validated).
182
+ * @param source - which root the copy lives in.
183
+ * @param workspacePath - canonical workspace path; required for project sources.
184
+ * @returns true when something was removed, false when nothing matched.
185
+ */
186
+ export function deleteSkillFile(name: string, source: ImportTarget, workspacePath?: string): boolean {
187
+ if (!KEBAB_CASE.test(name)) throw new Error('技能名必须是 kebab-case(小写字母、数字、短横线)')
188
+ if (source !== 'user' && workspacePath === undefined) {
189
+ throw new Error('项目来源需要 workspacePath(当前工作区路径)')
190
+ }
191
+ const root = skillRoot(source, workspacePath ?? '')
192
+ const directory = join(root, name)
193
+ if (existsSync(directory)) {
194
+ rmSync(directory, { recursive: true, force: true })
195
+ return true
196
+ }
197
+ const flat = join(root, `${name}.md`)
198
+ if (existsSync(flat)) {
199
+ rmSync(flat)
200
+ return true
201
+ }
202
+ return false
203
+ }
204
+
205
+ /** Host-only source candidate retained between scan and the one-time commit. */
206
+ interface BatchCandidate {
207
+ readonly id: string
208
+ readonly name: string
209
+ readonly description: string
210
+ readonly sourcePath: string
211
+ readonly sourceKind: 'directory' | 'file'
212
+ readonly fingerprint: string
213
+ }
214
+
215
+ /** One short-lived preflight. The HTTP layer owns the map and single-use lifecycle. */
216
+ export interface BatchScanSession {
217
+ readonly scanId: string
218
+ readonly sourcePath: string
219
+ readonly target: ImportTarget
220
+ readonly workspacePath?: string
221
+ readonly expiresAt: number
222
+ readonly entries: readonly BatchScanEntry[]
223
+ readonly candidates: ReadonlyMap<string, BatchCandidate>
224
+ }
225
+
226
+ interface SourceStats {
227
+ readonly fingerprint: string
228
+ readonly fileCount: number
229
+ readonly bytes: number
230
+ }
231
+
232
+ /** Hash names and bytes while refusing symlinks and oversized skill bundles. */
233
+ function sourceStats(sourcePath: string, kind: 'directory' | 'file'): SourceStats {
234
+ const hash = createHash('sha256')
235
+ let fileCount = 0
236
+ let bytes = 0
237
+ const visit = (path: string, relativePath: string): void => {
238
+ const stat = lstatSync(path)
239
+ if (stat.isSymbolicLink()) throw new Error('技能目录不能包含符号链接')
240
+ if (stat.isDirectory()) {
241
+ for (const entry of readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
242
+ visit(join(path, entry.name), relativePath.length === 0 ? entry.name : join(relativePath, entry.name))
243
+ }
244
+ return
245
+ }
246
+ if (!stat.isFile()) throw new Error('技能目录包含不支持的文件类型')
247
+ fileCount += 1
248
+ bytes += stat.size
249
+ if (fileCount > MAX_BATCH_FILES_PER_SKILL) throw new Error(`技能文件数量超过 ${MAX_BATCH_FILES_PER_SKILL}`)
250
+ if (bytes > MAX_BATCH_SKILL_BYTES) throw new Error(`技能目录超过 ${MAX_BATCH_SKILL_BYTES / 1024 / 1024} MB`)
251
+ hash.update(relativePath)
252
+ hash.update('\0')
253
+ hash.update(readFileSync(path))
254
+ hash.update('\0')
255
+ }
256
+ visit(sourcePath, kind === 'file' ? basename(sourcePath) : '')
257
+ return { fingerprint: hash.digest('hex'), fileCount, bytes }
258
+ }
259
+
260
+ function destinationExists(name: string, target: ImportTarget, workspacePath?: string): boolean {
261
+ const root = skillRoot(target, workspacePath ?? '')
262
+ return existsSync(join(root, name)) || existsSync(join(root, `${name}.md`))
263
+ }
264
+
265
+ /** Resolve immediate child skill directories and flat Markdown skills. */
266
+ function batchSources(root: string): Array<{ path: string; kind: 'directory' | 'file'; label: string }> {
267
+ const ownSkill = join(root, 'SKILL.md')
268
+ if (existsSync(ownSkill)) return [{ path: root, kind: 'directory', label: basename(root) }]
269
+ const sources: Array<{ path: string; kind: 'directory' | 'file'; label: string }> = []
270
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
271
+ const path = join(root, entry.name)
272
+ if (entry.isSymbolicLink()) continue
273
+ if (entry.isDirectory() && existsSync(join(path, 'SKILL.md'))) {
274
+ sources.push({ path, kind: 'directory', label: entry.name })
275
+ } else if (entry.isFile() && ['.md', '.markdown'].includes(extname(entry.name).toLowerCase())) {
276
+ sources.push({ path, kind: 'file', label: basename(entry.name, extname(entry.name)) })
277
+ }
278
+ }
279
+ return sources
280
+ }
281
+
282
+ /** Validate a selected skills root without writing anything. */
283
+ export function scanBatch(request: BatchScanRequest): BatchScanSession {
284
+ if (!isAbsolute(request.sourcePath)) throw new Error('批量导入目录必须是绝对路径')
285
+ if (!existsSync(request.sourcePath) || !statSync(request.sourcePath).isDirectory()) throw new Error('批量导入目录不存在或不可读')
286
+ const sourcePath = realpathSync(request.sourcePath)
287
+ const leaf = basename(sourcePath)
288
+ const parent = basename(dirname(sourcePath))
289
+ const grandparent = basename(dirname(dirname(sourcePath)))
290
+ const isSkillsRoot = leaf === 'skills' && AGENT_SKILL_PARENTS.has(parent)
291
+ const isSkillDirectory = parent === 'skills' && AGENT_SKILL_PARENTS.has(grandparent)
292
+ if (!isSkillsRoot && !isSkillDirectory) {
293
+ throw new Error('批量导入仅支持 .claude、.codex、.agents 或 .dsh 下的 skills 目录')
294
+ }
295
+ if (request.target !== 'user' && request.workspacePath === undefined) throw new Error('项目目标需要 workspacePath(当前工作区路径)')
296
+ const sources = batchSources(sourcePath)
297
+ if (sources.length === 0) throw new Error('所选目录中没有找到可导入的技能')
298
+ if (sources.length > MAX_BATCH_SKILLS) throw new Error(`一次最多扫描 ${MAX_BATCH_SKILLS} 个技能`)
299
+ const entries: BatchScanEntry[] = []
300
+ const candidates = new Map<string, BatchCandidate>()
301
+ const nameRows = new Map<string, number[]>()
302
+
303
+ for (const [index, source] of sources.entries()) {
304
+ const id = String(index + 1)
305
+ const relativePath = relative(sourcePath, source.path) || '.'
306
+ try {
307
+ const skillFile = source.kind === 'directory' ? join(source.path, 'SKILL.md') : source.path
308
+ const text = readFileSync(skillFile, 'utf8')
309
+ if (Buffer.byteLength(text, 'utf8') > MAX_CONTENT_BYTES) throw new Error(`SKILL.md 超过 ${MAX_CONTENT_BYTES / 1024} KB`)
310
+ const { frontmatter } = parseSkillFile(text)
311
+ if (frontmatter.name === undefined) throw new Error('frontmatter 缺少 name 字段')
312
+ if (!isValidSkillName(frontmatter.name)) throw new Error('name 必须是 kebab-case(小写字母、数字、短横线)')
313
+ if (frontmatter.description === undefined || frontmatter.description.trim().length === 0) throw new Error('frontmatter 缺少 description 字段')
314
+ // Reuse the canonical normalizer so scan and single-file import accept the same format.
315
+ normalizeSkillText(text)
316
+ const stats = sourceStats(source.path, source.kind)
317
+ const warnings = source.label === frontmatter.name
318
+ ? undefined
319
+ : [`目录或文件名“${source.label}”与技能名“${frontmatter.name}”不一致`]
320
+ entries.push({
321
+ id,
322
+ name: frontmatter.name,
323
+ description: frontmatter.description,
324
+ relativePath,
325
+ status: 'ready',
326
+ conflict: destinationExists(frontmatter.name, request.target, request.workspacePath),
327
+ ...(warnings === undefined ? {} : { warnings }),
328
+ })
329
+ candidates.set(id, {
330
+ id,
331
+ name: frontmatter.name,
332
+ description: frontmatter.description,
333
+ sourcePath: source.path,
334
+ sourceKind: source.kind,
335
+ fingerprint: stats.fingerprint,
336
+ })
337
+ const at = entries.length - 1
338
+ nameRows.set(frontmatter.name, [...(nameRows.get(frontmatter.name) ?? []), at])
339
+ } catch (error) {
340
+ entries.push({
341
+ id,
342
+ name: source.label,
343
+ relativePath,
344
+ status: 'error',
345
+ error: error instanceof Error ? error.message : String(error),
346
+ conflict: false,
347
+ })
348
+ }
349
+ }
350
+
351
+ // A duplicate inside the selected batch is ambiguous: every copy is refused.
352
+ for (const [name, indexes] of nameRows) {
353
+ if (indexes.length < 2) continue
354
+ for (const index of indexes) {
355
+ const entry = entries[index]
356
+ if (entry === undefined) continue
357
+ entries[index] = { ...entry, status: 'error', conflict: false, error: `批次内存在重复技能名:${name}` }
358
+ candidates.delete(entry.id)
359
+ }
360
+ }
361
+
362
+ return {
363
+ scanId: randomUUID(),
364
+ sourcePath,
365
+ target: request.target,
366
+ ...(request.workspacePath === undefined ? {} : { workspacePath: request.workspacePath }),
367
+ expiresAt: Date.now() + BATCH_SCAN_TTL_MS,
368
+ entries,
369
+ candidates,
370
+ }
371
+ }
372
+
373
+ /** Copy one validated candidate into a same-root staging directory, then atomically swap it in. */
374
+ function installBatchCandidate(candidate: BatchCandidate, session: BatchScanSession, replace: boolean): 'imported' | 'replaced' {
375
+ const fresh = sourceStats(candidate.sourcePath, candidate.sourceKind)
376
+ if (fresh.fingerprint !== candidate.fingerprint) throw new Error('源技能在确认期间发生变化,请重新扫描')
377
+ const root = skillRoot(session.target, session.workspacePath ?? '')
378
+ mkdirSync(root, { recursive: true })
379
+ const finalDirectory = join(root, candidate.name)
380
+ const flatFile = join(root, `${candidate.name}.md`)
381
+ const conflicts = [finalDirectory, flatFile].filter(existsSync)
382
+ if (conflicts.length > 0 && !replace) throw new Error('目标中已存在同名技能')
383
+ const staging = join(root, `.${candidate.name}.import-${randomUUID()}`)
384
+ const backups: Array<{ original: string; backup: string }> = []
385
+ try {
386
+ if (candidate.sourceKind === 'directory') {
387
+ cpSync(candidate.sourcePath, staging, { recursive: true, errorOnExist: true, dereference: false })
388
+ const skillFile = join(staging, 'SKILL.md')
389
+ writeFileSync(skillFile, normalizeSkillText(readFileSync(skillFile, 'utf8')), 'utf8')
390
+ } else {
391
+ mkdirSync(staging, { recursive: false })
392
+ writeFileSync(join(staging, 'SKILL.md'), normalizeSkillText(readFileSync(candidate.sourcePath, 'utf8')), 'utf8')
393
+ }
394
+ for (const original of conflicts) {
395
+ const backup = join(root, `.${basename(original)}.backup-${randomUUID()}`)
396
+ renameSync(original, backup)
397
+ backups.push({ original, backup })
398
+ }
399
+ renameSync(staging, finalDirectory)
400
+ for (const { backup } of backups) rmSync(backup, { recursive: true, force: true })
401
+ return conflicts.length > 0 ? 'replaced' : 'imported'
402
+ } catch (error) {
403
+ rmSync(staging, { recursive: true, force: true })
404
+ if (existsSync(finalDirectory) && backups.length > 0) rmSync(finalDirectory, { recursive: true, force: true })
405
+ for (const { original, backup } of backups.reverse()) {
406
+ if (existsSync(backup)) renameSync(backup, original)
407
+ }
408
+ throw error
409
+ }
410
+ }
411
+
412
+ /** Commit a valid, unexpired scan once; invalid rows are returned as errors and never written. */
413
+ export function commitBatch(session: BatchScanSession, replaceNames: ReadonlySet<string>): BatchCommitEntry[] {
414
+ if (Date.now() > session.expiresAt) throw new Error('批量扫描结果已过期,请重新扫描')
415
+ const results: BatchCommitEntry[] = []
416
+ for (const entry of session.entries) {
417
+ if (entry.status === 'error') {
418
+ results.push({ name: entry.name, status: 'error', message: entry.error })
419
+ continue
420
+ }
421
+ const candidate = session.candidates.get(entry.id)
422
+ if (candidate === undefined) {
423
+ results.push({ name: entry.name, status: 'error', message: '扫描记录不完整,请重新扫描' })
424
+ continue
425
+ }
426
+ const conflict = destinationExists(entry.name, session.target, session.workspacePath)
427
+ if (conflict && !replaceNames.has(entry.name)) {
428
+ results.push({ name: entry.name, status: 'skipped', message: '目标中已存在同名技能,未选择替换' })
429
+ continue
430
+ }
431
+ try {
432
+ results.push({ name: entry.name, status: installBatchCandidate(candidate, session, conflict && replaceNames.has(entry.name)) })
433
+ } catch (error) {
434
+ results.push({ name: entry.name, status: 'error', message: error instanceof Error ? error.message : String(error) })
435
+ }
436
+ }
437
+ return results
438
+ }
439
+
440
+ /** Strip HTML down to a plain-Markdown-ish text body (best effort). */
441
+ function extractHtmlText(html: string): string {
442
+ const withoutBlocks = html
443
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
444
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
445
+ .replace(/<!--[\s\S]*?-->/g, '')
446
+ const title = withoutBlocks.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? ''
447
+ const body = withoutBlocks
448
+ .replace(/<[^>]+>/g, ' ')
449
+ .replace(/&nbsp;/g, ' ')
450
+ .replace(/&amp;/g, '&')
451
+ .replace(/&lt;/g, '<')
452
+ .replace(/&gt;/g, '>')
453
+ .replace(/&quot;/g, '"')
454
+ .replace(/[ \t]{2,}/g, ' ')
455
+ .replace(/\n{3,}/g, '\n\n')
456
+ .trim()
457
+ return title.length > 0 ? `# ${title}\n\n${body}` : body
458
+ }
459
+
460
+ /**
461
+ * Fetch a URL's content for import. Markdown/plain responses pass through
462
+ * verbatim; HTML is roughly extracted to text (the import UI recommends
463
+ * `.md` sources).
464
+ * @param url - the source URL.
465
+ * @returns the text to write as the skill body.
466
+ */
467
+ function privateIpv4(address: string): boolean {
468
+ const octets = address.split('.').map(Number)
469
+ if (octets.length !== 4 || octets.some(value => !Number.isInteger(value) || value < 0 || value > 255)) return true
470
+ const [a = 0, b = 0, c = 0] = octets
471
+ return a === 0 || a === 10 || a === 127 || a >= 224
472
+ || (a === 100 && b >= 64 && b <= 127)
473
+ || (a === 169 && b === 254)
474
+ || (a === 172 && b >= 16 && b <= 31)
475
+ || (a === 192 && (b === 0 || b === 168 || (b === 0 && c === 2)))
476
+ || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
477
+ || (a === 203 && b === 0 && c === 113)
478
+ }
479
+
480
+ /** Whether an address is unsafe for a host-side URL import. */
481
+ export function isPrivateAddress(address: string): boolean {
482
+ const normalized = address.toLowerCase().split('%')[0] ?? ''
483
+ if (isIP(normalized) === 4) return privateIpv4(normalized)
484
+ if (isIP(normalized) !== 6) return true
485
+ if (normalized.startsWith('::ffff:')) return privateIpv4(normalized.slice(7))
486
+ return normalized === '::' || normalized === '::1'
487
+ || normalized.startsWith('fc') || normalized.startsWith('fd')
488
+ || /^fe[89ab]/.test(normalized)
489
+ || normalized.startsWith('ff')
490
+ || normalized.startsWith('2001:db8:')
491
+ }
492
+
493
+ type ResolveHost = (hostname: string) => Promise<readonly { readonly address: string }[]>
494
+
495
+ const resolveHost: ResolveHost = async hostname => lookup(hostname, { all: true, verbatim: true })
496
+
497
+ /** Parse and resolve one URL before the host is allowed to request it. */
498
+ export async function assertSafeImportUrl(input: string, resolver: ResolveHost = resolveHost): Promise<URL> {
499
+ let url: URL
500
+ try {
501
+ url = new URL(input)
502
+ } catch {
503
+ throw new Error('URL 格式无效')
504
+ }
505
+ if (url.protocol !== 'https:') throw new Error('URL 导入仅支持 HTTPS')
506
+ if (url.username.length > 0 || url.password.length > 0) throw new Error('URL 不能包含登录凭据')
507
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
508
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) throw new Error('URL 不能指向本机或私有网络')
509
+ const addresses = isIP(hostname) === 0 ? await resolver(hostname) : [{ address: hostname }]
510
+ if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
511
+ throw new Error('URL 不能指向本机、私有网络或保留地址')
512
+ }
513
+ return url
514
+ }
515
+
516
+ export async function fetchUrlContent(url: string): Promise<string> {
517
+ let current = await assertSafeImportUrl(url)
518
+ let response: Response | undefined
519
+ for (let redirects = 0; redirects <= MAX_URL_REDIRECTS; redirects += 1) {
520
+ response = await fetch(current, { redirect: 'manual', signal: AbortSignal.timeout(15_000) })
521
+ if (![301, 302, 303, 307, 308].includes(response.status)) break
522
+ if (redirects === MAX_URL_REDIRECTS) throw new Error(`重定向次数超过 ${MAX_URL_REDIRECTS}`)
523
+ const location = response.headers.get('location')
524
+ if (location === null) throw new Error('重定向响应缺少 Location')
525
+ current = await assertSafeImportUrl(new URL(location, current).href)
526
+ }
527
+ if (response === undefined) throw new Error('抓取失败')
528
+ if (!response.ok) throw new Error(`抓取失败:HTTP ${response.status}`)
529
+ const type = response.headers.get('content-type') ?? ''
530
+ const text = await response.text()
531
+ return type.includes('html') ? extractHtmlText(text) : text
532
+ }
533
+
534
+ /**
535
+ * Resolve one import request into a written file path. Shared by the file
536
+ * and URL routes; URL imports fetch first, then reuse the same write path.
537
+ * @param request - validated import body.
538
+ * @returns the absolute path of the written SKILL.md.
539
+ */
540
+ export async function resolveImport(request: ImportRequest | ImportUrlRequest): Promise<string> {
541
+ const content = 'content' in request
542
+ ? request.content
543
+ : await fetchUrlContent(request.url)
544
+ return writeSkillFile(request.name, request.target, content, request.workspacePath)
545
+ }
546
+
547
+ // ---- HTTP layer -----------------------------------------------------------
548
+
549
+ /** Read and parse a JSON request body within a byte cap. */
550
+ export function readJsonBody(req: IncomingMessage, limit: number = MAX_BODY_BYTES): Promise<unknown> {
551
+ return new Promise((resolve, reject) => {
552
+ const chunks: Buffer[] = []
553
+ let size = 0
554
+ req.on('data', (chunk: Buffer) => {
555
+ size += chunk.length
556
+ if (size > limit) {
557
+ reject(new Error('请求体过大'))
558
+ req.destroy()
559
+ return
560
+ }
561
+ chunks.push(chunk)
562
+ })
563
+ req.on('end', () => {
564
+ try {
565
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
566
+ } catch {
567
+ reject(new Error('无效的 JSON'))
568
+ }
569
+ })
570
+ req.on('error', reject)
571
+ })
572
+ }
573
+
574
+ /**
575
+ * Origin fence: the routes are served on the harness's loopback-only web
576
+ * server, so only the browser page itself (or a local curl) reaches them.
577
+ * A cross-origin page (any other website) is refused. Requests without an
578
+ * Origin header is rejected: all state-changing browser requests include it,
579
+ * while accepting an absent header would let non-browser clients bypass the fence.
580
+ */
581
+ export function originAllowed(req: IncomingMessage): boolean {
582
+ const origin = req.headers.origin
583
+ if (origin === undefined) return false
584
+ try {
585
+ const hostname = new URL(origin).hostname
586
+ return hostname === '127.0.0.1' || hostname === 'localhost'
587
+ } catch {
588
+ return false
589
+ }
590
+ }
591
+
592
+ /** Send one JSON response. */
593
+ export function sendJson(res: ServerResponse, status: number, body: unknown): void {
594
+ const payload = JSON.stringify(body)
595
+ res.writeHead(status, {
596
+ 'content-type': 'application/json; charset=utf-8',
597
+ 'content-length': Buffer.byteLength(payload),
598
+ })
599
+ res.end(payload)
600
+ }
601
+
602
+ /** Send a JSON error response with the given status. */
603
+ export function sendError(res: ServerResponse, status: number, error: string): void {
604
+ sendJson(res, status, { ok: false, error })
605
+ }