dsh-skill-hub 0.3.19 → 0.3.21

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 (51) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/lib/client.js +10 -6
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +428 -156
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/protocol/config.d.ts +17 -11
  9. package/lib/types/protocol/market.d.ts +11 -0
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/protocol.d.ts +1 -1
  12. package/lib/types/reconcile.d.ts +24 -0
  13. package/lib/types/repo/github-client.d.ts +11 -4
  14. package/lib/types/repo.d.ts +1 -1
  15. package/lib/types/routes/deps.d.ts +6 -1
  16. package/lib/types/routes/route-state.d.ts +22 -2
  17. package/lib/types/skillfs/scan.d.ts +32 -5
  18. package/lib/types/skillfs.d.ts +34 -2
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +21 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  24. package/src/client/settings-card.tsx +2 -1
  25. package/src/client/settings-form.ts +6 -3
  26. package/src/index.ts +12 -2
  27. package/src/protocol/config.ts +27 -24
  28. package/src/protocol/market.ts +12 -0
  29. package/src/protocol/sources.ts +5 -0
  30. package/src/protocol.ts +1 -0
  31. package/src/reconcile.test.ts +74 -1
  32. package/src/reconcile.ts +50 -1
  33. package/src/repo/github-client.ts +26 -14
  34. package/src/repo.test.ts +20 -1
  35. package/src/repo.ts +1 -1
  36. package/src/routes/catalog-data.ts +7 -4
  37. package/src/routes/catalog.ts +51 -28
  38. package/src/routes/deps.ts +7 -2
  39. package/src/routes/market.ts +13 -2
  40. package/src/routes/repo-import.ts +17 -30
  41. package/src/routes/route-state.test.ts +4 -4
  42. package/src/routes/route-state.ts +45 -10
  43. package/src/routes/sources.ts +79 -23
  44. package/src/routes.test.ts +300 -0
  45. package/src/skillfs/scan.ts +119 -17
  46. package/src/skillfs.test.ts +64 -3
  47. package/src/skillfs.ts +51 -8
  48. package/src/store/migrate.ts +6 -4
  49. package/src/store/store.ts +21 -11
  50. package/src/store.test.ts +17 -1
  51. package/src/update.ts +6 -6
@@ -89,29 +89,30 @@ export const HUB_CONFIG_DEFAULTS = {
89
89
  } as const
90
90
 
91
91
  /**
92
- * Resolve the effective hub config: saved sidecar overrides win over the
93
- * cordis composition entry (the web card owns runtime config), missing
94
- * booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
95
- * (saved first, then base) only when set. Numeric stats knobs are clamped to
96
- * their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
92
+ * Fill a raw settings value into the effective hub config: schema defaults for
93
+ * anything missing, and the numeric stats knobs clamped to sane ranges
94
+ * (window ≥ 0, scan interval ≥ 1 minute).
95
+ *
96
+ * There is deliberately no "saved sidecar layer" parameter. Until dsh 0.1.7 the
97
+ * hub kept its own runtime overrides in the sidecar and merged them here; the
98
+ * settings namespace is now the only user layer (`updateConfig` writes through
99
+ * `ctx.settings.mutate`), so a merge against a sidecar layer could never
100
+ * observe anything but `{}` — see the git history of `resolveHubConfig`.
97
101
  */
98
- export function resolveHubConfig(saved: Partial<HubConfig>, base: Partial<HubConfig> = {}): HubConfig {
99
- const dotModelColor = saved.dotModelColor !== undefined ? saved.dotModelColor : base.dotModelColor
100
- const dotUserColor = saved.dotUserColor !== undefined ? saved.dotUserColor : base.dotUserColor
101
- const githubToken = saved.githubToken !== undefined ? saved.githubToken : base.githubToken
102
- const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
103
- const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
102
+ export function resolveHubConfig(base: Partial<HubConfig> = {}): HubConfig {
103
+ const windowDays = clampNumber(base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
104
+ const scanMinutes = clampNumber(base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
104
105
  return {
105
- enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
106
- announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
107
- showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
108
- showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
109
- showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
106
+ enabled: base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
107
+ announceToAgent: base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
108
+ showUseCount: base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
109
+ showUseTime: base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
110
+ showGroupSummary: base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
110
111
  statsWindowDays: windowDays,
111
112
  statsScanMinutes: scanMinutes,
112
- ...(dotModelColor !== undefined ? { dotModelColor } : {}),
113
- ...(dotUserColor !== undefined ? { dotUserColor } : {}),
114
- ...(githubToken !== undefined ? { githubToken } : {}),
113
+ ...(base.dotModelColor !== undefined ? { dotModelColor: base.dotModelColor } : {}),
114
+ ...(base.dotUserColor !== undefined ? { dotUserColor: base.dotUserColor } : {}),
115
+ ...(base.githubToken !== undefined ? { githubToken: base.githubToken } : {}),
115
116
  }
116
117
  }
117
118
 
@@ -148,7 +149,13 @@ export interface ConfigResponse {
148
149
  githubTokenSet: boolean
149
150
  }
150
151
 
151
- /** POST /api/skill-hub/config — a partial patch; omitted fields keep their values. */
152
+ /**
153
+ * POST /api/skill-hub/config — a partial patch; omitted fields keep their values.
154
+ *
155
+ * Only the fields the handler actually parses belong here: the stats knobs are
156
+ * owned by the settings namespace (`statsWindowDays` / `statsScanMinutes` are
157
+ * still part of {@link HubSettingsValue}), not by this route.
158
+ */
152
159
  export interface ConfigRequest {
153
160
  /** Set the field; null clears the saved override so it re-inherits the default. */
154
161
  enabled?: boolean | null
@@ -164,10 +171,6 @@ export interface ConfigRequest {
164
171
  dotModelColor?: string | null
165
172
  /** Set the dot color; null clears the saved override so it re-inherits the default. */
166
173
  dotUserColor?: string | null
167
- /** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
168
- statsWindowDays?: number | null
169
- /** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
170
- statsScanMinutes?: number | null
171
174
  /** Set the GitHub token; null/empty clears it back to anonymous. */
172
175
  githubToken?: string | null
173
176
  }
@@ -27,6 +27,18 @@ export interface MarketSourceResponse {
27
27
  repos: MarketSourceRecord[]
28
28
  }
29
29
 
30
+ /**
31
+ * POST /api/skill-hub/market/source — same list, plus the slug the host
32
+ * resolved from the input. Clients must scan *this* slug rather than assume
33
+ * the acted-on source sits at some position in `repos`: re-adding a repo the
34
+ * user already tracks only updates its `ref` in place and never reorders the
35
+ * list.
36
+ */
37
+ export interface MarketSourceAddResponse extends MarketSourceResponse {
38
+ /** Normalized `owner/repo` the host recorded for the submitted input. */
39
+ repo: string
40
+ }
41
+
30
42
  /** POST /api/skill-hub/market/source/ref — pin a market source to an explicit ref. */
31
43
  export interface MarketSourceRefRequest {
32
44
  repo: string
@@ -138,6 +138,11 @@ export interface SourceTrashClearResponse {
138
138
  deleted: string[]
139
139
  /** 未能删除的技能(保留在回收站中)。 */
140
140
  failed: Array<{ name: string; error: string }>
141
+ /**
142
+ * `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
143
+ * 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
144
+ */
145
+ orphans?: string[]
141
146
  }
142
147
 
143
148
  /** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
package/src/protocol.ts CHANGED
@@ -36,6 +36,7 @@ export type {
36
36
  MarketSourceRecord,
37
37
  MarketSourcesResponse,
38
38
  MarketSourceRequest,
39
+ MarketSourceAddResponse,
39
40
  MarketSourceResponse,
40
41
  MarketSourceRefRequest,
41
42
  MarketSourceVersionsResponse,
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
- import { reconcileDisabledSkills } from './reconcile.ts'
5
+ import { pruneStaleDisabledSkills, reconcileDisabledSkills } from './reconcile.ts'
6
6
  import { SkillHubStore, statePath } from './store.ts'
7
7
 
8
8
  describe('reconcileDisabledSkills', () => {
@@ -73,4 +73,77 @@ describe('reconcileDisabledSkills', () => {
73
73
  expect(await reconcileDisabledSkills(store, home)).toEqual([])
74
74
  expect(await store.listDisabled()).toHaveLength(1)
75
75
  })
76
+
77
+ it('reconciles a root-level SKILL.md.disabled (a flat skill literally named SKILL)', async () => {
78
+ // scanRoot 会把根级 SKILL.md 文件当一个扁平技能,它的禁用对应物就不能被漏掉。
79
+ await writeFile(join(home, 'skills', 'SKILL.md.disabled'), '---\nname: root-flat\ndescription: Root flat\n---', 'utf8')
80
+ const added = await reconcileDisabledSkills(store, home)
81
+ expect(added.map((entry) => entry.name)).toEqual(['root-flat'])
82
+ expect((await store.getDisabled('root-flat'))?.path).toBe(join(home, 'skills', 'SKILL.md.disabled'))
83
+ })
84
+ })
85
+
86
+ describe('pruneStaleDisabledSkills', () => {
87
+ let dir: string
88
+ let home: string
89
+ let agentsHome: string
90
+ let previousAgentsHome: string | undefined
91
+ let store: SkillHubStore
92
+
93
+ beforeEach(async () => {
94
+ dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-prune-'))
95
+ home = join(dir, 'home')
96
+ agentsHome = join(dir, 'agents')
97
+ await mkdir(join(home, 'skills'), { recursive: true })
98
+ await mkdir(join(agentsHome, 'skills'), { recursive: true })
99
+ previousAgentsHome = process.env.DSH_AGENTS_HOME
100
+ process.env.DSH_AGENTS_HOME = agentsHome
101
+ store = new SkillHubStore(statePath(home))
102
+ })
103
+
104
+ afterEach(async () => {
105
+ if (previousAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
106
+ else process.env.DSH_AGENTS_HOME = previousAgentsHome
107
+ await rm(dir, { recursive: true, force: true })
108
+ })
109
+
110
+ it('drops a record whose file is gone and frees the name', async () => {
111
+ await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
112
+ const result = await pruneStaleDisabledSkills(store, home)
113
+ expect(result).toEqual({ repaired: [], removed: ['ghost'] })
114
+ expect(await store.listDisabled()).toEqual([])
115
+ })
116
+
117
+ it('repoints a record to the surviving flat placeholder instead of dropping it', async () => {
118
+ // 记录指目录形态,磁盘上只剩扁平形态(导入让位过一次的典型残留)。
119
+ await writeFile(join(home, 'skills', 'code-review.md.disabled'), '---\nname: code-review\ndescription: stub\n---', 'utf8')
120
+ await store.addDisabled({ name: 'code-review', description: 'stub', path: join(home, 'skills', 'code-review', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
121
+ const result = await pruneStaleDisabledSkills(store, home)
122
+ expect(result.removed).toEqual([])
123
+ expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'code-review.md.disabled')])
124
+ expect((await store.getDisabled('code-review'))?.path).toBe(join(home, 'skills', 'code-review.md.disabled'))
125
+ })
126
+
127
+ it('repoints to the bundle placeholder when that is the surviving form', async () => {
128
+ await mkdir(join(home, 'skills', 'paused'))
129
+ await writeFile(join(home, 'skills', 'paused', 'SKILL.md.disabled'), '---\nname: paused\ndescription: b\n---', 'utf8')
130
+ await store.addDisabled({ name: 'paused', description: 'b', path: join(home, 'skills', 'paused.md.disabled'), root: 'user-dsh', disabledAt: 1 })
131
+ const result = await pruneStaleDisabledSkills(store, home)
132
+ expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'paused', 'SKILL.md.disabled')])
133
+ })
134
+
135
+ it('leaves records whose file still exists alone, and is idempotent', async () => {
136
+ await writeFile(join(home, 'skills', 'alive.md.disabled'), '---\nname: alive\ndescription: x\n---', 'utf8')
137
+ await store.addDisabled({ name: 'alive', description: 'x', path: join(home, 'skills', 'alive.md.disabled'), root: 'user-dsh', disabledAt: 1 })
138
+ expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
139
+ expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
140
+ expect(await store.listDisabled()).toHaveLength(1)
141
+ })
142
+
143
+ it('prunes records from the other writable root too', async () => {
144
+ await store.addDisabled({ name: 'agent-ghost', description: 'x', path: join(agentsHome, 'skills', 'agent-ghost', 'SKILL.md.disabled'), root: 'user-agents', disabledAt: 1 })
145
+ await writeFile(join(agentsHome, 'skills', 'agent-ghost.md.disabled'), '---\nname: agent-ghost\ndescription: x\n---', 'utf8')
146
+ const result = await pruneStaleDisabledSkills(store, home)
147
+ expect(result.repaired.map((entry) => entry.path)).toEqual([join(agentsHome, 'skills', 'agent-ghost.md.disabled')])
148
+ })
76
149
  })
package/src/reconcile.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { readFile, stat } from 'node:fs/promises'
14
14
  import type { DisabledSkill } from './protocol.ts'
15
- import { parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
15
+ import { disabledOccupancyIn, parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
16
16
  import { dshHome } from './store.ts'
17
17
 
18
18
  /** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
@@ -21,6 +21,11 @@ export interface DisabledReconcileStore {
21
21
  addDisabled(entry: DisabledSkill): Promise<void>
22
22
  }
23
23
 
24
+ /** Narrow store view used by the reverse reconcile (adds record removal). */
25
+ export interface DisabledPruneStore extends DisabledReconcileStore {
26
+ removeDisabled(name: string): Promise<void>
27
+ }
28
+
24
29
  /**
25
30
  * Add sidecar disabled records for every `.disabled` discovery file that has
26
31
  * none yet. Existing records (matched by path or by name) win, so this never
@@ -60,3 +65,47 @@ export async function reconcileDisabledSkills(store: DisabledReconcileStore, hom
60
65
  }
61
66
  return added
62
67
  }
68
+
69
+ /** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
70
+ export interface DisabledPruneResult {
71
+ /** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
72
+ repaired: DisabledSkill[]
73
+ /** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
74
+ removed: string[]
75
+ }
76
+
77
+ /**
78
+ * 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
79
+ * 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
80
+ * 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
81
+ * 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
82
+ *
83
+ * 两种处理,都不动启用态技能:
84
+ * - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
85
+ * - 一个都没有 → 删记录,把名字还给用户。
86
+ *
87
+ * 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
88
+ */
89
+ export async function pruneStaleDisabledSkills(store: DisabledPruneStore, home = dshHome()): Promise<DisabledPruneResult> {
90
+ const result: DisabledPruneResult = { repaired: [], removed: [] }
91
+ for (const record of await store.listDisabled()) {
92
+ let exists = false
93
+ try {
94
+ exists = (await stat(record.path)).isFile()
95
+ } catch {
96
+ exists = false
97
+ }
98
+ if (exists) continue
99
+ if (!WRITABLE_ROOTS.includes(record.root)) continue
100
+ const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name)
101
+ if (occupancy === undefined) {
102
+ await store.removeDisabled(record.name)
103
+ result.removed.push(record.name)
104
+ continue
105
+ }
106
+ const repaired: DisabledSkill = { ...record, path: occupancy }
107
+ await store.addDisabled(repaired)
108
+ result.repaired.push(repaired)
109
+ }
110
+ return result
111
+ }
@@ -66,21 +66,34 @@ function etagCacheSet(url: string, etag: string, json: unknown): void {
66
66
  }
67
67
  etagCache.set(url, { etag, json })
68
68
  }
69
+
70
+ /**
71
+ * When the response shows an exhausted GitHub rate limit, the instant it
72
+ * resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
73
+ * own update check so both agree on what counts as a rate-limit rejection
74
+ * (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
75
+ * instead of each re-deriving it from the headers.
76
+ */
77
+ export function rateLimitResetAt(response: Response): Date | undefined {
78
+ if (response.status !== 403 && response.status !== 429) return undefined
79
+ if (response.headers.get('x-ratelimit-remaining') !== '0') return undefined
80
+ const reset = response.headers.get('x-ratelimit-reset')
81
+ if (reset === null) return undefined
82
+ const at = new Date(Number(reset) * 1000)
83
+ return Number.isNaN(at.getTime()) ? undefined : at
84
+ }
85
+
69
86
  /**
70
87
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
71
88
  * report the reset time instead of a bare HTTP status.
72
89
  */
73
90
  export function fetchError(context: string, response: Response, fallbackStatus = 502): RepoFetchError {
74
- const remaining = response.headers.get('x-ratelimit-remaining')
75
- const reset = response.headers.get('x-ratelimit-reset')
76
- if (response.status === 403 || response.status === 429) {
77
- if (remaining === '0' && reset !== null) {
78
- const at = new Date(Number(reset) * 1000)
79
- return new RepoFetchError(
80
- 'github rate limit reached (anonymous quota exhausted); retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN',
81
- 403,
82
- )
83
- }
91
+ const resetAt = rateLimitResetAt(response)
92
+ if (resetAt !== undefined) {
93
+ return new RepoFetchError(
94
+ 'github rate limit reached (anonymous quota exhausted); retry after ' + resetAt.toLocaleTimeString() + ' or set GITHUB_TOKEN',
95
+ 403,
96
+ )
84
97
  }
85
98
  return new RepoFetchError(context + ' (HTTP ' + response.status + ')', response.status === 404 ? 404 : fallbackStatus)
86
99
  }
@@ -111,10 +124,9 @@ export async function fetchJson(url: string, fetchImpl: typeof fetch, context: s
111
124
  }
112
125
 
113
126
  /**
114
- * Load repo metadata and the recursive git tree at the given ref. The meta
115
- * request always resolves the default branch (used when ref is absent); the
116
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
117
- * the exact same content a later download would fetch.
127
+ * Load one JSON document from the GitHub API, reusing an ETag when the URL was
128
+ * fetched before. `fetchImpl`/`context` exist so callers can stub the transport
129
+ * and shape the error text; the payload is whatever the endpoint returned.
118
130
  */
119
131
  export async function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{ json: unknown; response: Response }> {
120
132
  const cached = etagCache.get(url)
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
 
@@ -449,3 +449,22 @@ describe('GitHub 请求头', () => {
449
449
  expect(apiHeaders().accept).toBe('application/vnd.github+json')
450
450
  })
451
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
 
@@ -199,10 +199,13 @@ export async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promi
199
199
  if (iface !== undefined) applyInterface(row, iface)
200
200
  return row
201
201
  })
202
- const diagnostics = [
203
- ...(await scanDiagnostics('user-dsh', home)),
204
- ...(await scanDiagnostics('user-agents', home)),
205
- ]
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]
206
209
  return {
207
210
  ok: true,
208
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 } 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, disabledOccupancyIn, enableSkill, occupancyOf, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
22
22
  import { errorText } from '../error-text.ts'
23
23
  import {
24
24
  applyInterface,
@@ -146,23 +146,27 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
146
146
  // 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
147
147
  let trashResult: { path: string; source: string } | null = null
148
148
  if (!resolved.ok) {
149
- const disabled = await deps.store.getDisabled(name)
150
- if (disabled !== undefined) {
151
- // sidecar 里的路径可能是外部写入/损坏的:移动前必须确认它落在
152
- // 可写根内(正常路径由 disableSkill 保证,这里只拦异常数据)。
153
- if (rootOfPath(disabled.path, homeOf(deps)) === undefined) {
154
- writeError(res, 409, 'disabled skill path is outside the hub writable roots')
155
- return
156
- }
157
- // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
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 }
165
- } else {
149
+ const disabled = await deps.store.getDisabled(name)
150
+ if (disabled !== undefined) {
151
+ // sidecar 里的路径可能是外部写入/损坏的:移动前必须确认它落在
152
+ // 可写根内(正常路径由 disableSkill 保证,这里只拦异常数据)。
153
+ if (rootOfPath(disabled.path, homeOf(deps)) === undefined) {
154
+ writeError(res, 409, 'disabled skill path is outside the hub writable roots')
155
+ return
156
+ }
157
+ // 记录还在、文件已经不在(手工删了 / 从备份恢复过 sidecar):这条记录
158
+ // 只是残留,清掉即算删除完成,否则它会永久占住这个名字。
159
+ if (!await pathExists(disabled.path)) {
160
+ await deps.store.removeDisabled(name)
161
+ await deps.store.removeSkillFromTags(name)
162
+ deps.invalidate?.()
163
+ writeJson(res, 200, { ok: true, name, path: disabled.path } satisfies SkillDeleteResponse)
164
+ return
165
+ }
166
+ // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
167
+ // (trashSkill 认两种发现文件名,目录包按整目录搬)。
168
+ trashResult = await trashSkill(disabled.path)
169
+ } else {
166
170
  writeError(res, resolved.status, resolved.error); return
167
171
  }
168
172
  }
@@ -201,13 +205,28 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
201
205
  if (body.enabled === true) {
202
206
  const record = await deps.store.getDisabled(name)
203
207
  if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
208
+ // 记录里的路径可能已经失效(文件被手工删/搬走)。先在同 root 下找现存的
209
+ // 禁用占位:找得到就把记录指向它,找不到说明这条记录是残留 —— 清掉并把
210
+ // 名字还给用户,而不是让「启用」永远失败把名字锁死。
211
+ let disabledPath = record.path
212
+ if (!await pathExists(disabledPath)) {
213
+ // root 从记录路径反推,不直接信 sidecar 里的 root 字段(可能是损坏数据)。
214
+ const root = rootOfPath(disabledPath, homeOf(deps))
215
+ const occupancy = root === undefined ? undefined : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name)
216
+ if (occupancy === undefined) { await deps.store.removeDisabled(name)
217
+ deps.invalidate?.()
218
+ writeError(res, 409, 'disabled record was stale (its file no longer exists); the record has been removed — retry now')
219
+ return
220
+ }
221
+ disabledPath = occupancy
222
+ }
204
223
  try {
205
- await enableSkill(record.path)
224
+ await enableSkill(disabledPath)
206
225
  } catch (error) {
207
226
  // The renamed file may have vanished (external cleanup); report
208
227
  // precisely instead of a generic 500, like toggle-batch does.
209
228
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
210
- writeError(res, 409, 'disabled skill file is missing on disk: ' + record.path)
229
+ writeError(res, 409, 'disabled skill file is missing on disk: ' + disabledPath)
211
230
  return
212
231
  }
213
232
  throw error
@@ -280,12 +299,16 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
280
299
  const existing = await deps.skills.get(name)
281
300
  if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
282
301
  if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
283
- // A directory may exist without producing a registry entry (invalid
284
- // frontmatter — exactly what the diagnostics section reports).
285
- // Refuse to overwrite it instead of silently truncating its SKILL.md.
286
- const target = join(rootPath(root, homeOf(deps)), name)
287
- if (await pathExists(target)) {
288
- writeError(res, 409, 'skill directory already exists on disk: ' + name + ' (check the discovery diagnostics)')
302
+ // 建档前先看磁盘:三种占位都会让新目录与旧文件并存成同名两形态 ——
303
+ // 已启用的发现文件(frontmatter 坏了因此注册表看不见)、已禁用的占位
304
+ // (目录包或扁平)、以及没有 SKILL.md 的空壳目录(正是发现诊断在报的东西)。
305
+ const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name)
306
+ if (occupancy.enabled.length > 0) {
307
+ writeError(res, 409, 'a skill file already occupies this name on disk: ' + name + ' (check the discovery diagnostics)')
308
+ return
309
+ }
310
+ if (occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
311
+ writeError(res, 409, 'name is occupied by a disabled placeholder or an empty directory on disk: ' + name + ' (check the discovery diagnostics)')
289
312
  return
290
313
  }
291
314
  const path = await createSkill(root, name, readString(body, 'description'), homeOf(deps))
@@ -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
  {