dsh-skill-hub 0.2.6 → 0.3.0

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/index.ts CHANGED
@@ -21,6 +21,10 @@ import { SkillHubProvider } from './provider.ts'
21
21
  import { makeRoutes } from './routes.ts'
22
22
  import { createSkillStatsReader, type SkillStatsReader } from './stats.ts'
23
23
  import { SkillHubStore } from './store.ts'
24
+ import { cleanupLeftoverImportDirs } from './repo.ts'
25
+ import { dshHome } from './store.ts'
26
+ import { join } from 'node:path'
27
+ import { homedir } from 'node:os'
24
28
 
25
29
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
26
30
  export const name = 'skill-hub'
@@ -213,6 +217,20 @@ export function apply(ctx: Context, config?: Config): void {
213
217
  // build seeds the settings namespace from the saved sidecar config when the
214
218
  // namespace has no user section yet. Later edits live only in the settings
215
219
  // document; the sidecar keeps its (now-stale) copy untouched.
220
+ // Startup: 清理 Issue #3 遗留的 .*.import-* 临时目录(尽早回收,不阻塞)
221
+ void (async () => {
222
+ const home = dshHome()
223
+ const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')
224
+ for (const root of [join(home, 'skills'), join(agentsHome, 'skills')]) {
225
+ try {
226
+ const c = await cleanupLeftoverImportDirs(root)
227
+ if (c > 0) ctx.logger.info(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`)
228
+ } catch (error) {
229
+ ctx.logger.warn('[dsh-skill-hub] startup cleanup failed', error)
230
+ }
231
+ }
232
+ })()
233
+
216
234
  void (async () => {
217
235
  const legacy = await store.getConfig()
218
236
  if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
package/src/protocol.ts CHANGED
@@ -23,11 +23,16 @@ export const SKILL_HUB_API = {
23
23
  marketSync: '/api/skill-hub/market/source/sync',
24
24
  repo: '/api/skill-hub/repo',
25
25
  repoImport: '/api/skill-hub/repo/import',
26
+ repoImportProgress: '/api/skill-hub/repo/import/progress',
27
+ repoImportCancel: '/api/skill-hub/repo/import/cancel',
26
28
  update: '/api/skill-hub/update',
27
29
  groups: '/api/skill-hub/groups',
28
30
  tag: '/api/skill-hub/tag',
29
31
  tagDelete: '/api/skill-hub/tag/delete',
30
32
  tagMembers: '/api/skill-hub/tag/members',
33
+ tagReorder: '/api/skill-hub/tag/reorder',
34
+ collectionReorder: '/api/skill-hub/collections/reorder',
35
+ sourceGroupReorder: '/api/skill-hub/source-groups/reorder',
31
36
  sources: '/api/skill-hub/sources',
32
37
  sourceCheck: '/api/skill-hub/sources/check',
33
38
  sourceSync: '/api/skill-hub/sources/sync',
@@ -414,7 +419,7 @@ export interface MarketSyncResponse {
414
419
  skills: string[]
415
420
  }
416
421
 
417
- /** A skill discovered in a GitHub repo under skills/ or design-templates/. */
422
+ /** A skill discovered in a GitHub repo under any top-level root (e.g. skills/, templates/). */
418
423
  export interface RepoSkillEntry {
419
424
  /** Kebab-case skill name (directory basename). */
420
425
  name: string
@@ -434,8 +439,8 @@ export interface RepoSkillEntry {
434
439
  existing: boolean
435
440
  }
436
441
 
437
- /** Supported skill roots in a GitHub repo. */
438
- export type RepoRoot = 'skills' | 'design-templates'
442
+ /** Skill root in a GitHub repo: the top-level directory that contains skills (e.g. skills, design-templates, templates). Auto-derived from SKILL.md locations, not hard-coded. */
443
+ export type RepoRoot = string
439
444
 
440
445
  /** GET /api/skill-hub/repo — discover importable skills in a GitHub repo. */
441
446
  export interface RepoDiscoverResponse {
@@ -461,8 +466,49 @@ export interface RepoImportRequest {
461
466
  ref?: string
462
467
  }
463
468
 
464
- /** POST /api/skill-hub/repo/import */
469
+ /** POST /api/skill-hub/repo/import — now creates an async job (B方案) */
465
470
  export interface RepoImportResponse {
471
+ ok: true
472
+ jobId: string
473
+ total: number
474
+ totalBytes: number
475
+ }
476
+
477
+ /** GET /api/skill-hub/repo/import/progress?jobId=xxx */
478
+ export interface RepoImportProgressResponse {
479
+ ok: true
480
+ jobId: string
481
+ status: 'running' | 'done' | 'cancelled' | 'error'
482
+ total: number
483
+ done: number
484
+ /** 当前正在下载的 skill 名,done < total 时有效 */
485
+ current?: string
486
+ /** 当前正在下载的文件路径(相对 skill dir) */
487
+ currentFile?: string
488
+ /** 字节级进度 */
489
+ totalBytes: number
490
+ downloadedBytes: number
491
+ /** 下载速度 bytes/sec,running 时有效 */
492
+ bytesPerSecond?: number
493
+ imported: Array<{ name: string; origin: string; path: string }>
494
+ skipped: Array<{ name: string; reason: 'exists' }>
495
+ failed: Array<{ name: string; error: string }>
496
+ /** error 状态时的原因 */
497
+ error?: string
498
+ }
499
+
500
+ /** POST /api/skill-hub/repo/import/cancel */
501
+ export interface RepoImportCancelRequest {
502
+ jobId: string
503
+ }
504
+ export interface RepoImportCancelResponse {
505
+ ok: true
506
+ jobId: string
507
+ status: 'cancelled' | 'done'
508
+ }
509
+
510
+ /** @deprecated 旧同步响应,保留供类型兼容,实际已由 Progress 代替 */
511
+ export interface RepoImportLegacyResponse {
466
512
  ok: true
467
513
  imported: Array<{ name: string; origin: string; path: string }>
468
514
  skipped: Array<{ name: string; reason: 'exists' }>
@@ -507,12 +553,16 @@ export interface CollectionGroup {
507
553
  /** GET /api/skill-hub/groups */
508
554
  export interface GroupsResponse {
509
555
  ok: true
510
- /** 用户自定义 tag 分组(按创建顺序)。 */
556
+ /** 用户自定义 tag 分组(按拖拽顺序)。 */
511
557
  tags: SkillTag[]
512
- /** 系统集合组(按 origin 聚合,名称排序)。 */
558
+ /** 系统集合组(按拖拽顺序,兜底按名称)。 */
513
559
  collections: CollectionGroup[]
514
560
  /** skillName → 集合名 的完整映射。 */
515
561
  origins: Record<string, string>
562
+ /** 来源分组整体顺序(project / col:xxx / uncategorized-source),用于 SourcesView 顶层排序 */
563
+ sourceGroupOrder?: string[]
564
+ /** 兼容旧 collectionOrder 字段 */
565
+ collectionOrder?: string[]
516
566
  }
517
567
 
518
568
  /** POST /api/skill-hub/tag — 新建(缺省 id)或重命名(带 id)。 */
@@ -554,6 +604,39 @@ export interface TagMembersResponse {
554
604
  tags: SkillTag[]
555
605
  }
556
606
 
607
+ /** POST /api/skill-hub/tag/reorder — 拖拽重排场景分组 */
608
+ export interface TagReorderRequest {
609
+ /** 按新顺序排列的 tag id 列表(需包含全部 id) */
610
+ orderedIds: string[]
611
+ }
612
+ /** POST /api/skill-hub/tag/reorder */
613
+ export interface TagReorderResponse {
614
+ ok: true
615
+ tags: SkillTag[]
616
+ }
617
+
618
+ /** POST /api/skill-hub/collections/reorder — 拖拽重排来源集合 */
619
+ export interface CollectionReorderRequest {
620
+ /** 按新顺序排列的集合名列表(需包含全部 name) */
621
+ orderedNames: string[]
622
+ }
623
+ /** POST /api/skill-hub/collections/reorder */
624
+ export interface CollectionReorderResponse {
625
+ ok: true
626
+ collections: CollectionGroup[]
627
+ order: string[]
628
+ }
629
+
630
+ /** POST /api/skill-hub/source-groups/reorder — 拖拽重排来源顶层分组(project / collections / personal) */
631
+ export interface SourceGroupReorderRequest {
632
+ /** 按新顺序排列的顶层分组 key 列表(project / col:xxx / uncategorized-source) */
633
+ orderedKeys: string[]
634
+ }
635
+ export interface SourceGroupReorderResponse {
636
+ ok: true
637
+ order: string[]
638
+ }
639
+
557
640
  /** 来源跟踪记录:一组来自同一上游仓库根目录的技能。 */
558
641
  export interface SourceRecord {
559
642
  /** GitHub owner/repo。 */
package/src/repo.test.ts CHANGED
@@ -40,10 +40,16 @@ describe('originForRoot', () => {
40
40
  })
41
41
 
42
42
  it('splits by root when multiple roots are present', () => {
43
- const roots = new Set(['skills', 'design-templates'] as const)
43
+ const roots = new Set(['skills', 'design-templates'])
44
44
  expect(originForRoot('nexu-io/open-design', roots, 'skills')).toBe('nexu-io/open-design/skills')
45
45
  expect(originForRoot('nexu-io/open-design', roots, 'design-templates')).toBe('nexu-io/open-design/design-templates')
46
46
  })
47
+
48
+ it('works with arbitrary roots', () => {
49
+ const roots = new Set(['templates', 'workflows'])
50
+ expect(originForRoot('a/b', roots, 'templates')).toBe('a/b/templates')
51
+ expect(originForRoot('a/b', new Set(['my-root']), 'my-root')).toBe('a/b')
52
+ })
47
53
  })
48
54
 
49
55
  describe('collectRepoSkillFiles', () => {
@@ -59,7 +65,7 @@ describe('collectRepoSkillFiles', () => {
59
65
  })
60
66
 
61
67
  describe('discoverRepoEntries', () => {
62
- it('discovers skills and design-templates and computes origins/sizes', () => {
68
+ it('discovers skills and design-templates and computes origins/sizes (generic roots)', () => {
63
69
  const tree = [
64
70
  blob('skills/engineering/code-review/SKILL.md', 10),
65
71
  blob('skills/engineering/code-review/README.md', 5),
@@ -70,55 +76,85 @@ describe('discoverRepoEntries', () => {
70
76
  blob('plugins/examples/foo/SKILL.md', 99),
71
77
  ]
72
78
  const entries = discoverRepoEntries(tree, 'nexu-io/open-design', new Set(['tdd']))
73
- expect(entries.map((entry) => entry.name)).toEqual(['code-review', 'dashboard', 'tdd'])
74
- expect(entries[0].origin).toBe('nexu-io/open-design/skills')
75
- expect(entries[0].fileCount).toBe(2)
76
- expect(entries[0].totalBytes).toBe(15)
77
- expect(entries[1].origin).toBe('nexu-io/open-design/design-templates')
78
- expect(entries[1].fileCount).toBe(2)
79
- expect(entries[1].totalBytes).toBe(109)
80
- expect(entries[2].existing).toBe(true)
79
+ // generic: any top-level dir is a root, so docs/plugins are also discovered (4 roots -> split origins)
80
+ expect(entries.map((entry) => entry.name)).toEqual(['code-review', 'dashboard', 'example', 'foo', 'tdd'])
81
+ expect(entries.find((e) => e.name === 'code-review')?.origin).toBe('nexu-io/open-design/skills')
82
+ expect(entries.find((e) => e.name === 'code-review')?.fileCount).toBe(2)
83
+ expect(entries.find((e) => e.name === 'code-review')?.totalBytes).toBe(15)
84
+ expect(entries.find((e) => e.name === 'dashboard')?.origin).toBe('nexu-io/open-design/design-templates')
85
+ expect(entries.find((e) => e.name === 'dashboard')?.fileCount).toBe(2)
86
+ expect(entries.find((e) => e.name === 'dashboard')?.totalBytes).toBe(109)
87
+ expect(entries.find((e) => e.name === 'example')?.origin).toBe('nexu-io/open-design/docs')
88
+ expect(entries.find((e) => e.name === 'foo')?.origin).toBe('nexu-io/open-design/plugins')
89
+ expect(entries.find((e) => e.name === 'tdd')?.existing).toBe(true)
90
+ })
91
+
92
+ it('discovers arbitrary roots like templates and workflows', () => {
93
+ const tree = [
94
+ blob('templates/my-template/SKILL.md', 10),
95
+ blob('workflows/deploy/SKILL.md', 7),
96
+ blob('my-skills/foo/SKILL.md', 5),
97
+ ]
98
+ const entries = discoverRepoEntries(tree, 'a/b')
99
+ expect(entries.map((e) => e.root).sort()).toEqual(['my-skills', 'templates', 'workflows'])
100
+ expect(entries.map((e) => e.name).sort()).toEqual(['deploy', 'foo', 'my-template'])
101
+ })
102
+
103
+ it('ignores hidden roots and bare SKILL.md at depth 1', () => {
104
+ const tree = [
105
+ blob('.hidden/secret/SKILL.md', 1),
106
+ blob('SKILL.md', 1),
107
+ blob('skills/SKILL.md', 1),
108
+ blob('.github/workflows/ci/SKILL.md', 1),
109
+ ]
110
+ expect(discoverRepoEntries(tree, 'a/b')).toEqual([])
81
111
  })
82
112
 
83
- it('returns empty when no supported SKILL.md exists', () => {
113
+ it('returns empty when no SKILL.md exists', () => {
84
114
  expect(discoverRepoEntries([blob('README.md'), blob('skills/foo/README.md')], 'a/b')).toEqual([])
85
115
  })
86
116
  })
87
117
 
88
118
  describe('skillDirOf', () => {
89
119
  it('recovers the nested upstream directory from the manifest', () => {
90
- const source = { root: 'skills' as const, manifest: { 'skills/engineering/ask-matt/SKILL.md': 100, 'skills/engineering/ask-matt/OTHER.md': 5 } }
120
+ const source = { root: 'skills', manifest: { 'skills/engineering/ask-matt/SKILL.md': 100, 'skills/engineering/ask-matt/OTHER.md': 5 } }
91
121
  expect(skillDirOf(source, 'ask-matt')).toBe('skills/engineering/ask-matt')
92
122
  })
93
123
 
94
124
  it('finds the skill in the upstream tree when the manifest lacks an entry', () => {
95
- const source = { root: 'skills' as const }
125
+ const source = { root: 'skills' }
96
126
  const paths = ['skills/engineering/grill-with-docs/SKILL.md', 'skills/engineering/grill-with-docs/agents/openai.yaml']
97
127
  expect(skillDirOf(source, 'grill-with-docs', paths)).toBe('skills/engineering/grill-with-docs')
98
128
  })
99
129
 
100
130
  it('prefers the top-level-root match over a same-name skill elsewhere', () => {
101
- const source = { root: 'skills' as const }
131
+ const source = { root: 'skills' }
102
132
  const paths = ['docs/engineering/a/SKILL.md', 'skills/misc/a/SKILL.md']
103
133
  expect(skillDirOf(source, 'a', paths)).toBe('skills/misc/a')
104
134
  })
105
135
 
106
136
  it('follows an upstream move away from the manifest path', () => {
107
- const source = { root: 'skills' as const, manifest: { 'skills/old/ask-matt/SKILL.md': 100 } }
137
+ const source = { root: 'skills', manifest: { 'skills/old/ask-matt/SKILL.md': 100 } }
108
138
  const paths = ['skills/new/ask-matt/SKILL.md']
109
139
  expect(skillDirOf(source, 'ask-matt', paths)).toBe('skills/new/ask-matt')
110
140
  })
111
141
 
112
142
  it('falls back to root/name without a manifest entry or tree', () => {
113
- const source = { root: 'skills' as const }
143
+ const source = { root: 'skills' }
114
144
  expect(skillDirOf(source, 'plain')).toBe('skills/plain')
115
145
  })
146
+
147
+ it('works with arbitrary roots', () => {
148
+ const source = { root: 'templates', manifest: { 'templates/my-tmpl/SKILL.md': 10 } }
149
+ expect(skillDirOf(source, 'my-tmpl')).toBe('templates/my-tmpl')
150
+ expect(skillDirOf({ root: 'workflows' }, 'deploy', ['workflows/deploy/SKILL.md'])).toBe('workflows/deploy')
151
+ })
116
152
  })
117
153
 
118
154
  describe('diffRemoteSkills', () => {
119
155
  it('matches nested skills via the manifest and reports real deletions only', () => {
120
156
  const source = {
121
- root: 'skills' as const,
157
+ root: 'skills',
122
158
  skills: ['ask-matt', 'gone'],
123
159
  manifest: {
124
160
  'skills/engineering/ask-matt/SKILL.md': 100,
@@ -134,15 +170,21 @@ describe('diffRemoteSkills', () => {
134
170
  })
135
171
 
136
172
  it('does not report skills missing from the manifest as deleted when the tree has them', () => {
137
- const source = { root: 'skills' as const, skills: ['grill-with-docs'] }
173
+ const source = { root: 'skills', skills: ['grill-with-docs'] }
138
174
  const tree = [blob('skills/engineering/grill-with-docs/SKILL.md', 100)]
139
175
  expect(diffRemoteSkills(tree, source)).toEqual({ updated: ['grill-with-docs'], deleted: [] })
140
176
  })
141
177
 
142
178
  it('reports size changes as updated', () => {
143
- const source = { root: 'skills' as const, skills: ['a'], manifest: { 'skills/engineering/a/SKILL.md': 100 } }
179
+ const source = { root: 'skills', skills: ['a'], manifest: { 'skills/engineering/a/SKILL.md': 100 } }
144
180
  expect(diffRemoteSkills([blob('skills/engineering/a/SKILL.md', 120)], source)).toEqual({ updated: ['a'], deleted: [] })
145
181
  })
182
+
183
+ it('works with arbitrary roots', () => {
184
+ const source = { root: 'templates', skills: ['x'], manifest: { 'templates/x/SKILL.md': 10 } }
185
+ expect(diffRemoteSkills([blob('templates/x/SKILL.md', 11)], source)).toEqual({ updated: ['x'], deleted: [] })
186
+ expect(diffRemoteSkills([], source)).toEqual({ updated: [], deleted: ['x'] })
187
+ })
146
188
  })
147
189
 
148
190
  describe('downloadRepoSkill', () => {
package/src/repo.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * GitHub repository skill discovery/import helpers.
3
3
  *
4
4
  * Kept dependency-free and mostly pure so the root/origin rules are easy to
5
- * test. The two supported roots mirror the repositories we care about:
6
- * `skills/**` (standard skill bundles) and `design-templates/**` (template
7
- * skill bundles in nexu-io/open-design).
5
+ * test. Roots are auto-derived: any top-level directory that contains a
6
+ * `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
7
+ * `workflows/**`) is treated as a skill root. No hard-coded allowlist.
8
8
  */
9
9
 
10
10
  import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
@@ -13,9 +13,12 @@ import { isSkillName } from '@deepseek-ai/dsh-skill'
13
13
  import type { RepoRoot, RepoSkillEntry } from './protocol.ts'
14
14
  import { parseFrontmatter } from './skillfs.ts'
15
15
 
16
- /** Supported skill roots in a GitHub repo. */
16
+ /** Preferred display order for known roots; unknown roots sort alphabetically after these. */
17
17
  export const REPO_ROOTS: readonly RepoRoot[] = ['skills', 'design-templates']
18
18
 
19
+ /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
20
+ const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
21
+
19
22
  /** Parsed GitHub repository reference. */
20
23
  export interface RepoRef {
21
24
  owner: string
@@ -180,20 +183,25 @@ export function collectRepoSkillFiles(tree: readonly RepoTreeItem[], dir: string
180
183
  }
181
184
 
182
185
  /** Compute an origin collection name. Multiple roots split by root, one root keeps the repo slug. */
183
- export function originForRoot(repo: string, rootsPresent: ReadonlySet<RepoRoot>, root: RepoRoot): string {
186
+ export function originForRoot(repo: string, rootsPresent: ReadonlySet<string>, root: string): string {
184
187
  return rootsPresent.size > 1 ? `${repo}/${root}` : repo
185
188
  }
186
189
 
187
- /** Discover importable skills from a repo tree. Invalid names are ignored. */
190
+ /** Discover importable skills from a repo tree. Invalid names are ignored. Roots are auto-derived from the top-level directory of each SKILL.md. */
188
191
  export function discoverRepoEntries(tree: readonly RepoTreeItem[], repo: string, existingNames: ReadonlySet<string> = new Set()): RepoSkillEntry[] {
189
192
  const candidates: Array<{ root: RepoRoot; dir: string; name: string; path: string }> = []
190
- const rootsPresent = new Set<RepoRoot>()
193
+ const rootsPresent = new Set<string>()
191
194
  for (const item of tree) {
192
195
  if (item.type !== 'blob') continue
193
- const match = /^(skills|design-templates)\/(.+)\/SKILL\.md$/.exec(item.path)
194
- if (match === null) continue
195
- const root = match[1] as RepoRoot
196
+ // Any SKILL.md at depth >=2: top segment is the root, last segment is the skill name (may be nested like root/category/name/SKILL.md)
197
+ const slash = item.path.indexOf('/')
198
+ if (slash === -1) continue
199
+ if (!item.path.endsWith('/SKILL.md')) continue
200
+ const root = item.path.slice(0, slash)
201
+ if (!ROOT_RE.test(root)) continue
196
202
  const dir = item.path.slice(0, -'/SKILL.md'.length)
203
+ // dir must be at least root/name (reject bare root/SKILL.md)
204
+ if (dir === root || dir.length <= root.length + 1) continue
197
205
  const name = dir.slice(dir.lastIndexOf('/') + 1)
198
206
  if (!isSkillName(name)) continue
199
207
  rootsPresent.add(root)
@@ -231,28 +239,37 @@ export async function mapConcurrent<T, R>(items: readonly T[], limit: number, wo
231
239
  return results
232
240
  }
233
241
 
242
+ /** Whether an error is an abort (AbortError / signal). */
243
+ export function isAbortError(error: unknown): boolean {
244
+ return error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted') || error.message.includes('Abort'))
245
+ }
246
+
234
247
  /**
235
248
  * Download one GitHub file as a buffer. Tries raw.githubusercontent.com
236
249
  * first (no API quota); on any failure falls back to the api.github.com
237
250
  * contents endpoint with the raw media type (rate-limited, but reachable
238
251
  * from networks that block the raw host).
239
252
  */
240
- export async function downloadGitHubFile(repo: string, ref: string, path: string, fetchImpl: typeof fetch = fetch): Promise<Buffer> {
253
+ export async function downloadGitHubFile(repo: string, ref: string, path: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal): Promise<Buffer> {
254
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
241
255
  const encodedPath = path.split('/').map((segment) => encodeURIComponent(segment)).join('/')
242
256
  const rawUrl = `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/${encodedPath}`
243
257
  let firstError: string | null = null
244
258
  let response: Response | null = null
245
259
  try {
246
- response = await fetchImpl(rawUrl, { headers: githubAuthHeaders() })
260
+ response = await fetchImpl(rawUrl, { headers: githubAuthHeaders(), ...(signal !== undefined ? { signal } : {}) })
247
261
  } catch (error) {
262
+ if (isAbortError(error)) throw error
248
263
  firstError = error instanceof Error ? error.message : String(error)
249
264
  }
250
265
  if (response === null || !response.ok) {
266
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
251
267
  // Fallback: api.github.com/contents with the raw media type.
252
268
  const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`
253
269
  try {
254
- response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...githubAuthHeaders() } })
270
+ response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
255
271
  } catch (error) {
272
+ if (isAbortError(error)) throw error
256
273
  throw new RepoFetchError('download failed: ' + (firstError ?? (error instanceof Error ? error.message : String(error))))
257
274
  }
258
275
  }
@@ -262,6 +279,7 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
262
279
  try {
263
280
  return Buffer.from(await response.arrayBuffer())
264
281
  } catch (error) {
282
+ if (isAbortError(error)) throw error
265
283
  throw new RepoFetchError('download read failed: ' + (error instanceof Error ? error.message : String(error)))
266
284
  }
267
285
  }
@@ -278,22 +296,30 @@ export async function downloadRepoSkill(
278
296
  files: readonly RepoFile[],
279
297
  targetRoot: string,
280
298
  fetchImpl: typeof fetch = fetch,
299
+ signal?: AbortSignal,
300
+ onProgress?: (bytes: number, file: string) => void,
281
301
  ): Promise<{ targetDir: string; skillPath: string }> {
302
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
282
303
  const targetDir = join(targetRoot, entry.name)
283
304
  await mkdir(targetRoot, { recursive: true })
284
305
  // Dot-prefixed so a leftover temp dir can never surface as a skill in the
285
306
  // provider's discovery scan (scanRoot skips dot entries).
286
307
  const tempDir = await mkdtemp(join(targetRoot, '.' + entry.name + '.import-'))
308
+ let renamed = false
287
309
  try {
288
310
  await mapConcurrent(files, 6, async (file) => {
311
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
289
312
  const relative = file.path.slice(entry.dir.length + 1)
290
313
  if (relative === '' || relative.includes('..')) throw new RepoFetchError('unsafe repo path: ' + file.path)
291
314
  const target = join(tempDir, relative)
292
- const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl)
315
+ const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal)
293
316
  await mkdir(dirname(target), { recursive: true })
294
317
  await writeFile(target, buffer)
318
+ onProgress?.(buffer.length, relative)
295
319
  })
296
320
 
321
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
322
+
297
323
  let text: string
298
324
  try {
299
325
  text = await readFile(join(tempDir, 'SKILL.md'), 'utf8')
@@ -307,16 +333,53 @@ export async function downloadRepoSkill(
307
333
  }
308
334
 
309
335
  await rename(tempDir, targetDir)
336
+ renamed = true
310
337
  return { targetDir, skillPath: join(targetDir, 'SKILL.md') }
311
338
  } catch (error) {
312
- // Concurrent workers may still be writing when the batch fails; retry
313
- // the cleanup once after a beat before giving up.
314
- await rm(tempDir, { recursive: true, force: true }).catch(async () => {
315
- await new Promise((resolve) => setTimeout(resolve, 60))
316
- await rm(tempDir, { recursive: true, force: true }).catch(() => {})
317
- })
318
339
  throw error
340
+ } finally {
341
+ if (!renamed) {
342
+ // 尽力清理临时目录,失败只打日志,不再静默吞掉;finally 保证 abort/异常都能清理
343
+ try {
344
+ await rm(tempDir, { recursive: true, force: true })
345
+ } catch (firstError) {
346
+ // 并发 worker 可能仍在写入,稍等 60ms 重试一次
347
+ await new Promise((resolve) => setTimeout(resolve, 60))
348
+ try {
349
+ await rm(tempDir, { recursive: true, force: true })
350
+ } catch (secondError) {
351
+ console.warn(`[skill-hub] cleanup tempDir failed ${tempDir}:`, secondError instanceof Error ? secondError.message : String(secondError), 'first:', firstError instanceof Error ? firstError.message : String(firstError))
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 启动时扫描并清理残留的 `.*.import-*` 临时目录(Issue #3 第4点)。
360
+ * 越积越多的点前缀目录不会显示为 skill,但会占空间,尽早回收。
361
+ */
362
+ export async function cleanupLeftoverImportDirs(targetRoot: string): Promise<number> {
363
+ const { readdir, rm: rm2 } = await import('node:fs/promises')
364
+ let names: string[]
365
+ try {
366
+ names = await readdir(targetRoot)
367
+ } catch {
368
+ return 0
369
+ }
370
+ let cleaned = 0
371
+ for (const name of names) {
372
+ if (!/^\..*\.import-/.test(name)) continue
373
+ const full = join(targetRoot, name)
374
+ try {
375
+ await rm2(full, { recursive: true, force: true })
376
+ cleaned += 1
377
+ } catch (error) {
378
+ console.warn(`[skill-hub] startup cleanup failed ${full}:`, error instanceof Error ? error.message : String(error))
379
+ }
319
380
  }
381
+ if (cleaned > 0) console.warn(`[skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`)
382
+ return cleaned
320
383
  }
321
384
 
322
385
  // ------------------------------------------------------- source tracking
@@ -447,7 +510,7 @@ export function skillManifest(tree: readonly RepoTreeItem[], dir: string): Recor
447
510
  * resilient to incomplete manifests and upstream moves.
448
511
  */
449
512
  export function skillDirOf(
450
- source: { root: RepoRoot; manifest?: Record<string, number> },
513
+ source: { root: string; manifest?: Record<string, number> },
451
514
  name: string,
452
515
  treePaths?: readonly string[],
453
516
  ): string {
@@ -473,7 +536,7 @@ export function skillDirOf(
473
536
  */
474
537
  export function diffRemoteSkills(
475
538
  tree: readonly RepoTreeItem[],
476
- source: { root: RepoRoot; skills: readonly string[]; manifest?: Record<string, number> },
539
+ source: { root: string; skills: readonly string[]; manifest?: Record<string, number> },
477
540
  ): { updated: string[]; deleted: string[] } {
478
541
  const blobs = new Map<string, number>()
479
542
  for (const item of tree) {
@@ -513,7 +576,7 @@ export function diffRemoteSkills(
513
576
  }
514
577
 
515
578
  /** Minimal RepoSkillEntry for a tracked skill name (sync re-downloads by name). */
516
- export function repoSkillEntry(name: string, root: RepoRoot, repo: string): RepoSkillEntry {
579
+ export function repoSkillEntry(name: string, root: string, repo: string): RepoSkillEntry {
517
580
  return {
518
581
  name,
519
582
  dir: root + '/' + name,
@@ -488,7 +488,7 @@ describe('skill-hub routes', () => {
488
488
  expect((del.json() as import('./protocol.ts').MarketSourceResponse).repos).toEqual([{ repo: 'other/repo', ref: 'v2.0.0' }])
489
489
  })
490
490
 
491
- // ------------------------------------------------------- repo import
491
+ // ------------------------------------------------------- repo import (B方案 job+轮询)
492
492
  it('imports repo skills and records the upstream source', async () => {
493
493
  const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
494
494
  stubFetch([
@@ -506,9 +506,23 @@ describe('skill-hub routes', () => {
506
506
  const res = new FakeResponse()
507
507
  await routeFor(SKILL_HUB_API.repoImport).handler(fakeReq('POST', SKILL_HUB_API.repoImport, { repo: 'example/repo', paths: ['skills/code-review/SKILL.md'] }), res as never)
508
508
  expect(res.status).toBe(200)
509
- const body = res.json() as import('./protocol.ts').RepoImportResponse
510
- expect(body.imported).toHaveLength(1)
511
- expect(body.imported[0]).toMatchObject({ name: 'code-review', origin: 'example/repo' })
509
+ const created = res.json() as import('./protocol.ts').RepoImportResponse
510
+ expect(created.jobId).toMatch(/^imp_/)
511
+ expect(created.total).toBe(1)
512
+ // 轮询直到完成(后台任务选项2:关面板也继续跑,轮询查进度)
513
+ let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
514
+ for (let i = 0; i < 20; i++) {
515
+ await new Promise((r) => setTimeout(r, 50))
516
+ const progRes = new FakeResponse()
517
+ await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), progRes as never)
518
+ expect(progRes.status).toBe(200)
519
+ prog = progRes.json() as import('./protocol.ts').RepoImportProgressResponse
520
+ if (prog.status !== 'running') break
521
+ }
522
+ expect(prog).toBeDefined()
523
+ expect(prog!.status).toBe('done')
524
+ expect(prog!.imported).toHaveLength(1)
525
+ expect(prog!.imported[0]).toMatchObject({ name: 'code-review', origin: 'example/repo' })
512
526
  await expect(access(join(home, 'skills', 'code-review', 'SKILL.md'))).resolves.toBeUndefined()
513
527
  const sources = await store.listSources()
514
528
  expect(sources).toHaveLength(1)