dsh-skill-hub 0.3.18 → 0.3.20

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.
Files changed (49) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +14 -8
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +187 -102
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/client/locales/market.d.ts +1 -0
  9. package/lib/types/client/locales.d.ts +1 -0
  10. package/lib/types/protocol/config.d.ts +17 -11
  11. package/lib/types/protocol/market.d.ts +11 -0
  12. package/lib/types/protocol/repo.d.ts +6 -0
  13. package/lib/types/protocol.d.ts +1 -1
  14. package/lib/types/repo/github-client.d.ts +11 -4
  15. package/lib/types/repo.d.ts +1 -1
  16. package/lib/types/routes/catalog-data.d.ts +11 -2
  17. package/lib/types/routes/deps.d.ts +6 -1
  18. package/lib/types/routes/route-state.d.ts +5 -0
  19. package/lib/types/skillfs/scan.d.ts +6 -5
  20. package/lib/types/skillfs.d.ts +25 -1
  21. package/package.json +22 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/locales/market.ts +2 -0
  24. package/src/client/panel/RepoScanCard.tsx +2 -1
  25. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  26. package/src/client/panel/panel.module.css +10 -2
  27. package/src/client/settings-card.tsx +2 -1
  28. package/src/client/settings-form.ts +6 -3
  29. package/src/index.ts +1 -1
  30. package/src/protocol/config.ts +27 -24
  31. package/src/protocol/market.ts +12 -0
  32. package/src/protocol/repo.ts +6 -0
  33. package/src/protocol.ts +1 -0
  34. package/src/repo/github-client.ts +26 -14
  35. package/src/repo/install.ts +5 -2
  36. package/src/repo.test.ts +40 -1
  37. package/src/repo.ts +1 -1
  38. package/src/routes/catalog-data.ts +20 -6
  39. package/src/routes/catalog.ts +4 -9
  40. package/src/routes/deps.ts +7 -2
  41. package/src/routes/market.ts +13 -2
  42. package/src/routes/repo-import.ts +49 -8
  43. package/src/routes/route-state.test.ts +1 -0
  44. package/src/routes/route-state.ts +5 -0
  45. package/src/routes.test.ts +187 -0
  46. package/src/skillfs/scan.ts +30 -17
  47. package/src/skillfs.test.ts +64 -3
  48. package/src/skillfs.ts +39 -8
  49. package/src/update.ts +6 -6
package/src/repo.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, relativeToSkillDir, repoSkillEntry, repoSlug, skillDirOf, skillFileAt, skillManifest } from './repo.ts'
5
+ import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, rateLimitResetAt, relativeToSkillDir, repoSkillEntry, repoSlug, skillDirOf, skillFileAt, skillManifest } from './repo.ts'
6
6
  import type { RepoTreeItem } from './repo.ts'
7
7
  import type { RepoSkillEntry } from './protocol.ts'
8
8
 
@@ -366,6 +366,26 @@ describe('downloadGitHubFile', () => {
366
366
  expect(buffer.toString('utf8')).toContain('name: demo')
367
367
  expect(calls.length).toBe(2)
368
368
  expect(calls[1]).toContain('api.github.com/repos/example/repo/contents/skills/demo/SKILL.md')
369
+ expect(calls[1]).toContain('ref=main')
370
+ })
371
+
372
+ // issue #15: the fallback dropped the ref, so /contents/ served the default
373
+ // branch and a tag/branch-pinned import silently mixed main-branch content.
374
+ it('keeps the requested ref on the api fallback (never the default branch)', async () => {
375
+ const calls: string[] = []
376
+ const ref = 'andibrae/create-top-level-namespace'
377
+ const fetchImpl = async (url: string) => {
378
+ calls.push(url)
379
+ if (url.startsWith('https://raw.githubusercontent.com/')) throw new Error('raw blocked')
380
+ if (url.includes('/contents/')) return new Response('---\nname: demo\ndescription: x\n---\n\nref body', { status: 200 })
381
+ return new Response('nope', { status: 599 })
382
+ }
383
+ await downloadGitHubFile('example/repo', ref, 'skills/demo/SKILL.md', fetchImpl as typeof fetch)
384
+ expect(calls[1]).toBe(
385
+ 'https://api.github.com/repos/example/repo/contents/skills/demo/SKILL.md?ref=' + encodeURIComponent(ref),
386
+ )
387
+ // The un-ref'd form is exactly what served default-branch content.
388
+ expect(calls[1]).not.toBe('https://api.github.com/repos/example/repo/contents/skills/demo/SKILL.md')
369
389
  })
370
390
 
371
391
  it('reports the original error when both hosts fail', async () => {
@@ -429,3 +449,22 @@ describe('GitHub 请求头', () => {
429
449
  expect(apiHeaders().accept).toBe('application/vnd.github+json')
430
450
  })
431
451
  })
452
+
453
+ describe('rateLimitResetAt', () => {
454
+ const response = (status: number, headers: Record<string, string>): Response => new Response('', { status, headers })
455
+ const exhausted = { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1700000000' }
456
+
457
+ it('reports the reset instant for an exhausted 403 or 429', () => {
458
+ expect(rateLimitResetAt(response(403, exhausted))?.getTime()).toBe(1700000000 * 1000)
459
+ expect(rateLimitResetAt(response(429, exhausted))?.getTime()).toBe(1700000000 * 1000)
460
+ })
461
+
462
+ it('is undefined for anything that is not an exhausted rate limit', () => {
463
+ expect(rateLimitResetAt(response(500, exhausted))).toBeUndefined()
464
+ expect(rateLimitResetAt(response(404, exhausted))).toBeUndefined()
465
+ expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '57', 'x-ratelimit-reset': '1700000000' }))).toBeUndefined()
466
+ expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '0' }))).toBeUndefined()
467
+ // 头部存在但不是数字:宁可退回普通 HTTP 文案,也不要报 "Invalid Date"。
468
+ expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': 'nope' }))).toBeUndefined()
469
+ })
470
+ })
package/src/repo.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  // 后向兼容:老 `from './repo.ts'` 写法继续可用,新代码可直引 github-client。
17
- export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts'
17
+ export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, rateLimitResetAt, setGithubToken } from './repo/github-client.ts'
18
18
 
19
19
  export type { RepoRef, RepoTreeItem, RepoFile } from './repo/types.ts'
20
20
 
@@ -17,10 +17,21 @@ import { rootPath, readSkillInterface, scanDiagnostics, type SkillInterface } fr
17
17
  import { CURRENT_VERSION } from '../update.ts'
18
18
  import { homeOf, isWritableSource, type SkillHubRouteDeps } from './deps.ts'
19
19
 
20
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
21
- export async function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>> {
20
+ /**
21
+ * 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
22
+ * 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
23
+ *
24
+ * 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
25
+ * 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
26
+ * (issue #16)。
27
+ */
28
+ export async function knownSkillNames(
29
+ deps: SkillHubRouteDeps,
30
+ options?: { includeDisabled?: boolean },
31
+ ): Promise<Set<string>> {
22
32
  const snapshot = await deps.skills.snapshot()
23
33
  const names = new Set(snapshot.skills.map((skill) => skill.name))
34
+ if (options?.includeDisabled === false) return names
24
35
  for (const disabled of await deps.store.listDisabled()) names.add(disabled.name)
25
36
  return names
26
37
  }
@@ -188,10 +199,13 @@ export async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promi
188
199
  if (iface !== undefined) applyInterface(row, iface)
189
200
  return row
190
201
  })
191
- const diagnostics = [
192
- ...(await scanDiagnostics('user-dsh', home)),
193
- ...(await scanDiagnostics('user-agents', home)),
194
- ]
202
+ // 两个 root 的诊断扫描互不依赖:面板每 5 秒轮询一次 /catalog,串行会让响应
203
+ // 随技能数量线性变慢。扫描内部的逐条读盘也已是并发的(见 scanDiagnostics)。
204
+ const [userDshDiagnostics, userAgentsDiagnostics] = await Promise.all([
205
+ scanDiagnostics('user-dsh', home),
206
+ scanDiagnostics('user-agents', home),
207
+ ])
208
+ const diagnostics = [...userDshDiagnostics, ...userAgentsDiagnostics]
195
209
  return {
196
210
  ok: true,
197
211
  pluginVersion: CURRENT_VERSION,
@@ -3,8 +3,8 @@
3
3
  * create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
4
4
  */
5
5
 
6
- import { mkdir, readFile, rename, stat } from 'node:fs/promises'
7
- import { basename, dirname, join } from 'node:path'
6
+ import { readFile, stat } from 'node:fs/promises'
7
+ import { dirname, join } from 'node:path'
8
8
  import type { SkillDefinition } from '@deepseek-ai/dsh-skill'
9
9
  import { isSkillName } from '@deepseek-ai/dsh-skill'
10
10
  import {
@@ -18,7 +18,7 @@ import {
18
18
  type ToggleResponse,
19
19
  type WritableRoot,
20
20
  } from '../protocol.ts'
21
- import { createSkill, disableSkill, enableSkill, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
21
+ import { createSkill, disableSkill, enableSkill, moveIntoTrash, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
22
22
  import { errorText } from '../error-text.ts'
23
23
  import {
24
24
  applyInterface,
@@ -156,12 +156,7 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
156
156
  }
157
157
  // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
158
158
  const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
159
- const sourceToTrash = isBundleDisabled ? dirname(disabled.path) : disabled.path
160
- const trashDir = join(dirname(sourceToTrash), '.trash')
161
- await mkdir(trashDir, { recursive: true })
162
- const target = join(trashDir, basename(sourceToTrash) + '-' + Date.now())
163
- await rename(sourceToTrash, target)
164
- trashResult = { path: target, source: sourceToTrash }
159
+ trashResult = await moveIntoTrash(isBundleDisabled ? dirname(disabled.path) : disabled.path)
165
160
  } else {
166
161
  writeError(res, resolved.status, resolved.error); return
167
162
  }
@@ -43,10 +43,15 @@ export interface SkillHubRouteDeps {
43
43
 
44
44
  /** The resolved hub config a route sees (the shared resolver fills defaults). */
45
45
  export function configOf(deps: SkillHubRouteDeps): HubConfig {
46
- return resolveHubConfig({}, deps.config?.() ?? {})
46
+ return resolveHubConfig(deps.config?.() ?? {})
47
47
  }
48
48
 
49
- /** The raw saved config layer a route reports (empty when the owner omits it). */
49
+ /**
50
+ * The raw saved config layer as persisted in the sidecar, reported for display
51
+ * only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
52
+ * is legacy data: nothing writes it any more and the effective config above
53
+ * never merges it.
54
+ */
50
55
  export function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig> {
51
56
  return deps.saved?.() ?? {}
52
57
  }
@@ -7,6 +7,7 @@
7
7
  import {
8
8
  SKILL_HUB_API,
9
9
  type MarketCheckResponse,
10
+ type MarketSourceAddResponse,
10
11
  type MarketSourceResponse,
11
12
  type MarketSourcesResponse,
12
13
  type MarketSourceVersionsResponse,
@@ -60,6 +61,13 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
60
61
  }
61
62
  try {
62
63
  const latestTag = await getLatestReleaseTag(source.repo)
64
+ // Book the attempt as soon as the GitHub call has been spent, before
65
+ // the unpinned branch below can `continue`: otherwise an unpinned
66
+ // source never records a timestamp and the 5-minute throttle promised
67
+ // above never applies to it (every check re-hits /releases/latest).
68
+ // A call that throws is deliberately not booked, so a failing repo is
69
+ // retried on the next check — same as the source check in sources.ts.
70
+ lastMarketCheck.set(source.repo, now)
63
71
  if (source.ref === undefined) {
64
72
  // Not pinned yet: report the latest release so the UI can
65
73
  // suggest pinning, but never claim an update.
@@ -67,7 +75,6 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
67
75
  continue
68
76
  }
69
77
  const latest = await getLatestCommit(source.repo, source.ref)
70
- lastMarketCheck.set(source.repo, now)
71
78
  const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
72
79
  const newRelease = latestTag !== undefined && latestTag !== source.ref
73
80
  results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
@@ -99,7 +106,11 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
99
106
  // An explicit @ref pins the source immediately (e.g. repo@v1.2.3);
100
107
  // otherwise the first scan resolves latest-release / branch choice.
101
108
  const ref = parsed.ref
102
- writeJson(res, 200, { ok: true, repos: await deps.store.addMarketSource(repoSlug(parsed), ref) } satisfies MarketSourceResponse)
109
+ const slug = repoSlug(parsed)
110
+ // Return the normalized slug explicitly: the client scans it right
111
+ // away, and `repos` order carries no "this one" signal (re-adding an
112
+ // existing repo only rewrites its ref in place).
113
+ writeJson(res, 200, { ok: true, repo: slug, repos: await deps.store.addMarketSource(slug, ref) } satisfies MarketSourceAddResponse)
103
114
  },
104
115
  },
105
116
  {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { mkdir } from 'node:fs/promises'
7
- import { join } from 'node:path'
7
+ import { dirname, join } from 'node:path'
8
8
  import { randomUUID } from 'node:crypto'
9
9
  import {
10
10
  SKILL_HUB_API,
@@ -28,7 +28,7 @@ import {
28
28
  skillManifest,
29
29
  skillPathIn,
30
30
  } from '../repo.ts'
31
- import { rootPath } from '../skillfs.ts'
31
+ import { moveIntoTrash, rootPath } from '../skillfs.ts'
32
32
  import { errorText } from '../error-text.ts'
33
33
  import {
34
34
  homeOf,
@@ -44,6 +44,29 @@ import {
44
44
  } from './helpers.ts'
45
45
  import { gcImportJobs, importJobs, type ImportJob } from './route-state.ts'
46
46
 
47
+ /**
48
+ * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
49
+ * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
50
+ * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
51
+ *
52
+ * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
53
+ * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
54
+ */
55
+ async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
56
+ const record = await deps.store.getDisabled(name)
57
+ if (record === undefined) return false
58
+ const dir = join(targetRoot, name)
59
+ const sourceToTrash = record.path.endsWith('SKILL.md.disabled') ? dirname(record.path) : record.path
60
+ if (sourceToTrash !== dir) return false
61
+ // 目录里还有 SKILL.md 说明它其实是启用态的真实技能,不动。
62
+ if (await pathExists(join(dir, 'SKILL.md'))) return false
63
+ // 与 skill/delete 走同一个回收站落盘规则(moveIntoTrash),恢复流程依赖它。
64
+ const { path: target } = await moveIntoTrash(sourceToTrash)
65
+ await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
66
+ await deps.store.removeDisabled(name)
67
+ return true
68
+ }
69
+
47
70
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
48
71
  export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
49
72
  return [
@@ -86,7 +109,8 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
86
109
  }
87
110
  }
88
111
  const { ref: resolvedRef, tree, truncated } = await loadRepoTree(repo, ref)
89
- const existing = await knownSkillNames(deps)
112
+ // 发现态也只看「已启用」:否则已禁用占位会把真技能标成「已存在」并禁掉勾选(issue #16)。
113
+ const existing = await knownSkillNames(deps, { includeDisabled: false })
90
114
  const entries = discoverRepoEntries(tree, repo, existing)
91
115
  writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries, ...(truncated ? { truncated: true } : {}) } satisfies RepoDiscoverResponse)
92
116
  },
@@ -108,7 +132,8 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
108
132
  const requestedRef = readString(body, 'ref')
109
133
  const ref = requestedRef !== '' ? requestedRef : parsed.ref
110
134
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
111
- const existing = await knownSkillNames(deps)
135
+ // 导入去重同 discover:已禁用不算「已存在」(issue #16)。
136
+ const existing = await knownSkillNames(deps, { includeDisabled: false })
112
137
  const entries = discoverRepoEntries(tree, repo, existing)
113
138
  // truncated tree is still usable for the selected paths the user already picked from the prior discover response
114
139
  const byPath = new Map(entries.map((entry) => [entry.path, entry]))
@@ -139,6 +164,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
139
164
  imported: [],
140
165
  skipped: [],
141
166
  failed: [],
167
+ replaced: [],
142
168
  status: 'running',
143
169
  controller,
144
170
  createdAt: now,
@@ -166,11 +192,25 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
166
192
  job.done += 1
167
193
  continue
168
194
  }
195
+ // 磁盘上有同名目录。若它只是一个已禁用的占位(目录里只剩
196
+ // SKILL.md.disabled),先把旧目录移入回收站再继续导入;移不动或
197
+ // 不是这种形态时退回原本的「跳过」(issue #16)。
169
198
  if (await pathExists(join(targetRoot, entry.name))) {
170
- job.skipped.push({ name: entry.name, reason: 'exists' })
171
- job.downloadedBytes += entry.totalBytes
172
- job.done += 1
173
- continue
199
+ let cleared = false
200
+ try {
201
+ cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
202
+ } catch {
203
+ cleared = false
204
+ }
205
+ if (!cleared) {
206
+ job.skipped.push({ name: entry.name, reason: 'exists' })
207
+ job.downloadedBytes += entry.totalBytes
208
+ job.done += 1
209
+ continue
210
+ }
211
+ // 旧目录已经进了回收站(可恢复)。即使随后的下载失败也必须如实
212
+ // 上报,否则用户不知道自己的占位被挪走了。
213
+ job.replaced.push(entry.name)
174
214
  }
175
215
  if (controller.signal.aborted) break
176
216
  const files = collectRepoSkillFiles(tree, entry.dir)
@@ -241,6 +281,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
241
281
  imported: [...job.imported],
242
282
  skipped: [...job.skipped],
243
283
  failed: [...job.failed],
284
+ ...(job.replaced.length > 0 ? { replaced: [...job.replaced] } : {}),
244
285
  ...(job.error !== undefined ? { error: job.error } : {}),
245
286
  } satisfies RepoImportProgressResponse)
246
287
  },
@@ -16,6 +16,7 @@ function job(jobId: string, createdAt: number, status: ImportJob['status'] = 'do
16
16
  imported: [],
17
17
  skipped: [],
18
18
  failed: [],
19
+ replaced: [],
19
20
  status,
20
21
  controller: new AbortController(),
21
22
  createdAt,
@@ -51,6 +51,11 @@ export interface ImportJob {
51
51
  imported: Array<{ name: string; origin: string; path: string }>
52
52
  skipped: Array<{ name: string; reason: 'exists' }>
53
53
  failed: Array<{ name: string; error: string }>
54
+ /**
55
+ * 导入时被移入回收站让位的「已禁用同名占位」名字(issue #16)。占位已挪走
56
+ * 即计入,即使随后的下载失败也在列 —— 否则用户不知道自己的占位不见了。
57
+ */
58
+ replaced: string[]
54
59
  status: 'running' | 'done' | 'cancelled' | 'error'
55
60
  error?: string
56
61
  controller: AbortController
@@ -5,6 +5,7 @@ import type { IncomingMessage } from 'node:http'
5
5
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
7
  import { makeRoutes, type SkillHubRouteDeps } from './routes.ts'
8
+ import { lastMarketCheck } from './routes/route-state.ts'
8
9
  import { SkillHubStore, statePath } from './store.ts'
9
10
  import { SKILL_HUB_API, SKILL_HUB_API_ROOT, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
10
11
 
@@ -645,6 +646,73 @@ describe('skill-hub routes', () => {
645
646
  expect((del.json() as import('./protocol.ts').MarketSourceResponse).repos).toEqual([{ repo: 'other/repo', ref: 'v2.0.0' }])
646
647
  })
647
648
 
649
+ // 回归:重复添加一个已存在的源只改它的 ref、不重排列表,所以响应必须回传
650
+ // 服务端归一化的 slug —— 客户端不能靠 `repos[repos.length - 1]` 猜操作对象。
651
+ it('returns the resolved slug of the added market source, not a list position', async () => {
652
+ const add = async (repo: string): Promise<import('./protocol.ts').MarketSourceAddResponse> => {
653
+ const res = new FakeResponse()
654
+ await routeFor(SKILL_HUB_API.marketSource).handler(fakeReq('POST', SKILL_HUB_API.marketSource, { repo }), res as never)
655
+ expect(res.status).toBe(200)
656
+ return res.json() as import('./protocol.ts').MarketSourceAddResponse
657
+ }
658
+ await add('first/repo')
659
+ await add('second/repo')
660
+ const again = await add('first/repo@v9.9.9')
661
+ expect(again.repo).toBe('first/repo')
662
+ // 本次操作对象并不在末位:按位置猜会拿到 second/repo。
663
+ expect(again.repos[again.repos.length - 1].repo).toBe('second/repo')
664
+ expect(again.repos).toEqual([{ repo: 'first/repo', ref: 'v9.9.9' }, { repo: 'second/repo' }])
665
+ // URL 输入也归一到同一个 slug。
666
+ expect((await add('https://github.com/third/repo')).repo).toBe('third/repo')
667
+ })
668
+
669
+ // 回归:未定版的市场源也必须吃 5 分钟节流。曾经把节流时间戳写在「未定版
670
+ // 早退」之后,于是未定版的源每次检查都重新打 GitHub /releases/latest。
671
+ it('throttles the market check for an unpinned source as well', async () => {
672
+ lastMarketCheck.clear()
673
+ await store.addMarketSource('unpinned/throttle-target')
674
+ let releaseLookups = 0
675
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
676
+ if (url.includes('/releases/latest')) {
677
+ releaseLookups += 1
678
+ return jsonResponse({ tag_name: 'v3.1.4' })
679
+ }
680
+ return new Response('unexpected url: ' + url, { status: 599 })
681
+ }))
682
+ try {
683
+ const first = new FakeResponse()
684
+ await routeFor(SKILL_HUB_API.marketCheck).handler(fakeReq('GET', SKILL_HUB_API.marketCheck), first as never)
685
+ const firstBody = first.json() as import('./protocol.ts').MarketCheckResponse
686
+ expect(firstBody.results[0]).toMatchObject({ repo: 'unpinned/throttle-target', latestTag: 'v3.1.4' })
687
+ expect(firstBody.results[0].throttled).toBeUndefined()
688
+
689
+ const second = new FakeResponse()
690
+ await routeFor(SKILL_HUB_API.marketCheck).handler(fakeReq('GET', SKILL_HUB_API.marketCheck), second as never)
691
+ const secondBody = second.json() as import('./protocol.ts').MarketCheckResponse
692
+ expect(secondBody.results[0].throttled).toBe(true)
693
+ // 第二次没有发生任何上游查询。
694
+ expect(releaseLookups).toBe(1)
695
+ } finally {
696
+ vi.unstubAllGlobals()
697
+ lastMarketCheck.clear()
698
+ }
699
+ })
700
+
701
+ // resolveHubConfig 收敛为单参数后,越界值仍必须回落到 schema 默认,
702
+ // 而不是被写进有效配置。
703
+ it('clamps the numeric stats knobs and fills the remaining defaults', async () => {
704
+ const routes = makeRoutes({
705
+ ...deps,
706
+ config: () => ({ enabled: true, announceToAgent: true, statsWindowDays: -5, statsScanMinutes: 0 }),
707
+ })
708
+ const res = new FakeResponse()
709
+ await routes.find((r) => r.path === SKILL_HUB_API.config)?.handler(fakeReq('GET', SKILL_HUB_API.config), res as never)
710
+ expect(res.status).toBe(200)
711
+ const body = res.json() as ConfigResponse
712
+ expect(body.config.statsWindowDays).toBe(14)
713
+ expect(body.config.statsScanMinutes).toBe(5)
714
+ })
715
+
648
716
  // ------------------------------------------------------- repo import (B方案 job+轮询)
649
717
  it('imports repo skills and records the upstream source', async () => {
650
718
  const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
@@ -695,6 +763,125 @@ describe('skill-hub routes', () => {
695
763
  }
696
764
  })
697
765
 
766
+ // ------------------------------------ 导入去重 vs 已禁用占位(issue #16)
767
+ /** 造一个「已禁用同名占位」:目录还在,SKILL.md 被改名成 SKILL.md.disabled。 */
768
+ async function seedDisabledPlaceholder(name: string) {
769
+ const dir = join(home, 'skills', name)
770
+ await mkdir(dir, { recursive: true })
771
+ await writeFile(join(dir, 'SKILL.md.disabled'), `---\nname: ${name}\ndescription: stub\n---\n`, 'utf8')
772
+ await store.addDisabled({
773
+ name,
774
+ description: 'stub',
775
+ path: join(dir, 'SKILL.md.disabled'),
776
+ root: 'user-dsh',
777
+ disabledAt: Date.now(),
778
+ })
779
+ return dir
780
+ }
781
+
782
+ it('does not flag a hub-disabled placeholder as existing when discovering', async () => {
783
+ await seedDisabledPlaceholder('code-review')
784
+ stubFetch([
785
+ ['repos/example/repo/releases/latest', jsonResponse({ tag_name: 'v1.0.0' })],
786
+ ['repos/example/repo/git/trees/v1.0.0', jsonResponse({ tree: [
787
+ { path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
788
+ ] })],
789
+ ['repos/example/repo', jsonResponse({ default_branch: 'main' })],
790
+ ])
791
+ try {
792
+ const res = new FakeResponse()
793
+ await routeFor(SKILL_HUB_API.repo).handler(fakeReq('GET', `${SKILL_HUB_API.repo}?repo=example/repo`), res as never)
794
+ expect(res.status).toBe(200)
795
+ const body = res.json() as import('./protocol.ts').RepoDiscoverResponse
796
+ expect(body.ref).toBe('v1.0.0')
797
+ // 禁用只该影响启用态;被算成 existing 会让勾选框直接 disabled,用户无从下手。
798
+ expect(body.entries).toHaveLength(1)
799
+ expect(body.entries[0]).toMatchObject({ name: 'code-review', existing: false })
800
+ } finally {
801
+ vi.unstubAllGlobals()
802
+ }
803
+ })
804
+
805
+ it('imports over a hub-disabled placeholder instead of skipping it', async () => {
806
+ const dir = await seedDisabledPlaceholder('code-review')
807
+ const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
808
+ stubFetch([
809
+ ['repos/example/repo/git/trees/main', jsonResponse({ tree: [
810
+ { path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
811
+ ] })],
812
+ ['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
813
+ ['repos/example/repo', jsonResponse({ default_branch: 'main' })],
814
+ ['raw.githubusercontent.com/example/repo/main/skills/code-review/SKILL.md', new Response(skillMd, { status: 200 })],
815
+ ])
816
+ try {
817
+ const res = new FakeResponse()
818
+ 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)
819
+ expect(res.status).toBe(200)
820
+ const created = res.json() as import('./protocol.ts').RepoImportResponse
821
+ let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
822
+ for (let i = 0; i < 20; i++) {
823
+ await new Promise((r) => setTimeout(r, 50))
824
+ const progRes = new FakeResponse()
825
+ await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), progRes as never)
826
+ prog = progRes.json() as import('./protocol.ts').RepoImportProgressResponse
827
+ if (prog.status !== 'running') break
828
+ }
829
+ expect(prog!.status).toBe('done')
830
+ // 关键:不再被「已存在」跳掉,真技能落地。
831
+ expect(prog!.skipped).toEqual([])
832
+ expect(prog!.imported.map((r) => r.name)).toEqual(['code-review'])
833
+ expect(prog!.replaced).toEqual(['code-review'])
834
+ await expect(readFile(join(dir, 'SKILL.md'), 'utf8')).resolves.toContain('name: code-review')
835
+ // 旧占位进回收站(可恢复),禁用记录被清掉,不然目录会留个幽灵禁用项。
836
+ await expect(access(join(dir, 'SKILL.md.disabled'))).rejects.toThrow()
837
+ expect(await store.listDisabled()).toEqual([])
838
+ const trash = await store.listTrash()
839
+ expect(trash).toHaveLength(1)
840
+ expect(trash[0].name).toBe('code-review')
841
+ expect(trash[0].path.startsWith(join(home, 'skills', '.trash', 'code-review-'))).toBe(true)
842
+ expect(trash[0].sourcePath).toBe(dir)
843
+ expect(await store.listSources()).toHaveLength(1)
844
+ } finally {
845
+ vi.unstubAllGlobals()
846
+ }
847
+ })
848
+
849
+ it('still skips a same-name directory that is not a disabled placeholder', async () => {
850
+ // 同名目录里还有真实 SKILL.md、且没有对应的禁用记录:不是占位,不许动。
851
+ const dir = join(home, 'skills', 'code-review')
852
+ await mkdir(dir, { recursive: true })
853
+ await writeFile(join(dir, 'SKILL.md'), '---\nname: code-review\ndescription: real\n---\n', 'utf8')
854
+ stubFetch([
855
+ ['repos/example/repo/git/trees/main', jsonResponse({ tree: [
856
+ { path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
857
+ ] })],
858
+ ['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
859
+ ['repos/example/repo', jsonResponse({ default_branch: 'main' })],
860
+ ])
861
+ try {
862
+ const res = new FakeResponse()
863
+ 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)
864
+ const created = res.json() as import('./protocol.ts').RepoImportResponse
865
+ let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
866
+ for (let i = 0; i < 20; i++) {
867
+ await new Promise((r) => setTimeout(r, 50))
868
+ const progRes = new FakeResponse()
869
+ await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), progRes as never)
870
+ prog = progRes.json() as import('./protocol.ts').RepoImportProgressResponse
871
+ if (prog.status !== 'running') break
872
+ }
873
+ expect(prog!.status).toBe('done')
874
+ expect(prog!.skipped).toEqual([{ name: 'code-review', reason: 'exists' }])
875
+ expect(prog!.imported).toEqual([])
876
+ expect(prog!.replaced ?? []).toEqual([])
877
+ // 原目录原封不动,也没进回收站。
878
+ await expect(readFile(join(dir, 'SKILL.md'), 'utf8')).resolves.toContain('description: real')
879
+ expect(await store.listTrash()).toEqual([])
880
+ } finally {
881
+ vi.unstubAllGlobals()
882
+ }
883
+ })
884
+
698
885
  // -------------------------------------------------------------- groups
699
886
  it('serves groups: user tags + system collections + origins', async () => {
700
887
  const tag = await store.saveTag({ name: 'web' })
@@ -4,8 +4,9 @@ import { load } from 'js-yaml'
4
4
  import { parseFrontmatter, repairFrontmatterFileText } from './frontmatter.ts'
5
5
  import { rootPath } from './paths.ts'
6
6
  import { errorText } from '../error-text.ts'
7
+ import { mapConcurrent } from '../concurrency.ts'
7
8
  import { dshHome } from '../store.ts'
8
- import type { DiagnosticEntry, WritableRoot } from '../protocol.ts'
9
+ import { HEX_COLOR_RE, type DiagnosticEntry, type WritableRoot } from '../protocol.ts'
9
10
 
10
11
  /** One discoverable skill file in a scanned root. */
11
12
  export interface SkillEntry {
@@ -130,7 +131,9 @@ export async function readSkillInterface(directory: string): Promise<SkillInterf
130
131
  const hexColor = (value: unknown): string | undefined => {
131
132
  if (typeof value !== 'string') return undefined
132
133
  const trimmed = value.trim()
133
- return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined
134
+ // Shared with the settings card and the config route: one definition, so a
135
+ // change to the accepted color format cannot land in only some of them.
136
+ return HEX_COLOR_RE.test(trimmed) ? trimmed : undefined
134
137
  }
135
138
  const iconPath = (value: unknown): string | undefined => {
136
139
  if (typeof value !== 'string' || value.trim() === '') return undefined
@@ -167,33 +170,40 @@ export async function findProjectRoot(cwd: string): Promise<string> {
167
170
  }
168
171
 
169
172
  /**
170
- * Scan one writable root for files the provider ignores, so the GUI can
171
- * show why a skill never appears — a
172
- * missing frontmatter must be visible, not silent). .disabled files belong
173
- * to the hub and are skipped.
173
+ * Scan one writable root for files the provider ignores, so the GUI can show
174
+ * why a skill never appears — a missing frontmatter, a name that disagrees
175
+ * with the discovery path, and the like must be visible, not silent. Files the
176
+ * hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
177
+ * a defect.
174
178
  */
175
179
  export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Promise<DiagnosticEntry[]> {
176
- const diagnostics: DiagnosticEntry[] = []
177
- for (const entry of await listSkillEntries(root, home)) {
180
+ const entries = await listSkillEntries(root, home)
181
+ // Each entry is read and parsed independently, and the panel polls /catalog
182
+ // every 5 seconds. A serial read would make the response time grow linearly
183
+ // with the skill count — the same reason the per-skill `stat` collection in
184
+ // routes/catalog-data.ts is already parallel. Bounded, not a bare
185
+ // Promise.all: a root can hold hundreds of entries.
186
+ const perEntry = await mapConcurrent(entries, 8, async (entry): Promise<DiagnosticEntry[]> => {
187
+ const found: DiagnosticEntry[] = []
178
188
  let text: string
179
189
  try {
180
190
  text = await readFile(entry.path, 'utf8')
181
191
  } catch (error) {
182
192
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
183
- diagnostics.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
193
+ found.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
184
194
  }
185
- continue
195
+ return found
186
196
  }
187
197
  const parsed = parseFrontmatter(text)
188
198
  if ('error' in parsed) {
189
199
  const fixable = repairFrontmatterFileText(text) !== null
190
- diagnostics.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
191
- continue
200
+ found.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
201
+ return found
192
202
  }
193
203
  // File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
194
204
  const repaired = repairFrontmatterFileText(text)
195
205
  if (repaired !== null) {
196
- diagnostics.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
206
+ found.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
197
207
  }
198
208
  const { value } = parsed
199
209
  // The provider registers a skill by its discovery path (directory name or
@@ -201,13 +211,16 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
201
211
  // the skill show up under a different identity than its metadata claims.
202
212
  const pathName = entry.kind === 'directory' ? basename(entry.directory) : basename(entry.path, '.md')
203
213
  if (value.name !== pathName) {
204
- diagnostics.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
214
+ found.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
205
215
  }
206
216
  // Agents decide to auto-activate a skill from its one-line description, so
207
217
  // a too-short description leaves the skill hard to discover automatically.
208
218
  if (value.description.length < 10) {
209
- diagnostics.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
219
+ found.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
210
220
  }
211
- }
212
- return diagnostics
221
+ return found
222
+ })
223
+ // mapConcurrent preserves index order, so flattening keeps the previous
224
+ // per-entry ordering of the diagnostics.
225
+ return perEntry.flat()
213
226
  }