dsh-skill-hub 0.3.19 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -66,7 +66,8 @@
66
66
  "0.1.7-rc.1",
67
67
  "0.1.7-rc.2",
68
68
  "0.2.0-rc.1",
69
- "0.2.0-rc.2"
69
+ "0.2.0-rc.2",
70
+ "0.2.1-alpha.2"
70
71
  ]
71
72
  },
72
73
  "capability": {
@@ -86,7 +87,7 @@
86
87
  "js-yaml": "^4.1.0"
87
88
  },
88
89
  "peerDependencies": {
89
- "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2",
90
+ "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2 || ^4.0.5-alpha.1",
90
91
  "@deepseek-ai/dsh-client-connection": ">=0.1.7-alpha.1 <0.3.0-0",
91
92
  "@deepseek-ai/dsh-client-locale": ">=0.1.7-alpha.1 <0.3.0-0",
92
93
  "@deepseek-ai/dsh-client-runtime": ">=0.1.7-alpha.1 <0.3.0-0",
@@ -100,7 +101,7 @@
100
101
  "@deepseek-ai/dsh-settings": ">=0.1.7-alpha.1 <0.3.0-0",
101
102
  "@deepseek-ai/dsh-skill": ">=0.1.7-alpha.1 <0.3.0-0",
102
103
  "@deepseek-ai/dsh-system-prompt": ">=0.1.7-alpha.1 <0.3.0-0",
103
- "@deepseek-ai/schemastery": "^3.18.3",
104
+ "@deepseek-ai/schemastery": "^3.18.3 || ^3.18.5-alpha.1",
104
105
  "react": "^18.2.0",
105
106
  "react-dom": "^18.2.0"
106
107
  },
@@ -113,22 +114,22 @@
113
114
  }
114
115
  },
115
116
  "devDependencies": {
116
- "@deepseek-ai/cordis": "^4.0.2",
117
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
118
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
119
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
120
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.0-rc.2",
121
- "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.2",
122
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
123
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.0-rc.2",
124
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
125
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
126
- "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
127
- "@deepseek-ai/dsh-session-query": "^0.2.0-rc.2",
128
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
129
- "@deepseek-ai/dsh-skill": "^0.2.0-rc.2",
130
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
131
- "@deepseek-ai/schemastery": "^3.18.3",
117
+ "@deepseek-ai/cordis": "^4.0.5-alpha.1",
118
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-alpha.2",
119
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-alpha.2",
120
+ "@deepseek-ai/dsh-client-store": "^0.2.1-alpha.2",
121
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.1-alpha.2",
122
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.1-alpha.2",
123
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-alpha.2",
124
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.1-alpha.2",
125
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-alpha.2",
126
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-alpha.2",
127
+ "@deepseek-ai/dsh-session": "^0.2.1-alpha.2",
128
+ "@deepseek-ai/dsh-session-query": "^0.2.1-alpha.2",
129
+ "@deepseek-ai/dsh-settings": "^0.2.1-alpha.2",
130
+ "@deepseek-ai/dsh-skill": "^0.2.1-alpha.2",
131
+ "@deepseek-ai/dsh-system-prompt": "^0.2.1-alpha.2",
132
+ "@deepseek-ai/schemastery": "^3.18.5-alpha.1",
132
133
  "@types/js-yaml": "^4.0.9",
133
134
  "@types/node": "^22.20.0",
134
135
  "@types/react": "~18.3.1",
package/src/client/api.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type DiagnosticFixResponse,
13
13
  type GroupsResponse,
14
14
  type MarketCheckResponse,
15
+ type MarketSourceAddResponse,
15
16
  type MarketSourceRefRequest,
16
17
  type MarketSourceRequest,
17
18
  type MarketSourceResponse,
@@ -156,9 +157,9 @@ export class SkillHubApi {
156
157
  return this.get<MarketSourcesResponse>(SKILL_HUB_API.market)
157
158
  }
158
159
 
159
- /** Add a market source repo; resolves with the fresh list. */
160
- addMarketSource(repo: string): Promise<MarketSourcesResponse> {
161
- return this.post<MarketSourceResponse>(SKILL_HUB_API.marketSource, { repo } satisfies MarketSourceRequest)
160
+ /** Add a market source repo; resolves with the fresh list plus the resolved slug. */
161
+ addMarketSource(repo: string): Promise<MarketSourceAddResponse> {
162
+ return this.post<MarketSourceAddResponse>(SKILL_HUB_API.marketSource, { repo } satisfies MarketSourceRequest)
162
163
  }
163
164
 
164
165
  /** Remove a market source repo; resolves with the fresh list. */
@@ -75,8 +75,10 @@ export function useMarketFlow(
75
75
  setNewSourceName('')
76
76
  setRepoResult(null)
77
77
  setRepoSelected(new Set())
78
- // 用服务端归一化后的 slug 扫描(URL/owner@repo 输入都能对上记录)。
79
- const slug = result.repos[result.repos.length - 1].repo
78
+ // 用服务端回传的归一化 slug 扫描(URL/owner@repo 输入都能对上记录)。
79
+ // 不能猜 `repos` 的位置:重复添加一个已存在的源只改它的 ref、不重排,
80
+ // 取末位会扫到另一个源。
81
+ const slug = result.repo
80
82
  setScanningRepo(slug)
81
83
  setRepoDiscoverState({ status: 'scanning' })
82
84
  try {
@@ -10,6 +10,7 @@ import { useState, type ReactElement } from 'react'
10
10
  import { IconChevronDownOutline14 } from './icons.tsx'
11
11
  import type { HubKey } from './locales.ts'
12
12
  import type { CardShell } from './settings-form.ts'
13
+ import { HEX_COLOR_RE } from '../protocol.ts'
13
14
  import css from './settings-card.module.css'
14
15
 
15
16
  /** Card-level chrome props. */
@@ -178,7 +179,7 @@ export interface ColorFieldProps extends FieldBaseProps {
178
179
  }
179
180
 
180
181
  export function ColorField(props: ColorFieldProps): ReactElement {
181
- const value = /^#[0-9a-f]{6}$/i.test(props.text) ? props.text : props.defaultColor
182
+ const value = HEX_COLOR_RE.test(props.text) ? props.text : props.defaultColor
182
183
  return (
183
184
  <div className={css.field}>
184
185
  <FieldShell {...props} />
@@ -10,6 +10,9 @@
10
10
  */
11
11
 
12
12
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
13
+ // One definition of what a valid color/token looks like, shared with the host
14
+ // routes that re-validate the same drafts.
15
+ import { GITHUB_TOKEN_RE, HEX_COLOR_RE } from '../protocol.ts'
13
16
 
14
17
  /** One staged field: its draft text plus the clear/reset marker. */
15
18
  interface StagedEdit {
@@ -41,9 +44,9 @@ export function booleanField(field: string): FieldSpec {
41
44
  export function colorField(field: string): FieldSpec {
42
45
  return {
43
46
  field,
44
- format: (value) => (typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : ''),
47
+ format: (value) => (typeof value === 'string' && HEX_COLOR_RE.test(value) ? value : ''),
45
48
  parse: (text) => {
46
- if (/^#[0-9a-f]{6}$/i.test(text)) return { kind: 'set', value: text }
49
+ if (HEX_COLOR_RE.test(text)) return { kind: 'set', value: text }
47
50
  return undefined
48
51
  },
49
52
  }
@@ -61,7 +64,7 @@ export function secretField(field: string): FieldSpec {
61
64
  parse: (text) => {
62
65
  const trimmed = text.trim()
63
66
  if (trimmed === '') return undefined
64
- if (!/^[A-Za-z0-9_-]{8,255}$/.test(trimmed)) return undefined
67
+ if (!GITHUB_TOKEN_RE.test(trimmed)) return undefined
65
68
  return { kind: 'set', value: trimmed }
66
69
  },
67
70
  }
package/src/index.ts CHANGED
@@ -163,7 +163,7 @@ export function apply(ctx: Context, config?: Config): void {
163
163
  }
164
164
  return out as Partial<HubConfig>
165
165
  }
166
- const current = (): HubConfig => resolveHubConfig({}, readConfig())
166
+ const current = (): HubConfig => resolveHubConfig(readConfig())
167
167
 
168
168
  /** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
169
169
  const settingsOf = (): SettingsForms | undefined => ctx.get('settings') as SettingsForms | undefined
@@ -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
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,
@@ -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, 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
  {
@@ -3,7 +3,7 @@
3
3
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
4
4
  */
5
5
 
6
- import { mkdir, rename } from 'node:fs/promises'
6
+ import { mkdir } from 'node:fs/promises'
7
7
  import { dirname, join } from 'node:path'
8
8
  import { randomUUID } from 'node:crypto'
9
9
  import {
@@ -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,
@@ -60,11 +60,8 @@ async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: str
60
60
  if (sourceToTrash !== dir) return false
61
61
  // 目录里还有 SKILL.md 说明它其实是启用态的真实技能,不动。
62
62
  if (await pathExists(join(dir, 'SKILL.md'))) return false
63
- // 与 skill/delete 的回收站语义一致:同级 .trash/ 下按「名字-时间戳」落盘,可恢复。
64
- const trashDir = join(targetRoot, '.trash')
65
- await mkdir(trashDir, { recursive: true })
66
- const target = join(trashDir, name + '-' + Date.now())
67
- await rename(sourceToTrash, target)
63
+ // 与 skill/delete 走同一个回收站落盘规则(moveIntoTrash),恢复流程依赖它。
64
+ const { path: target } = await moveIntoTrash(sourceToTrash)
68
65
  await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
69
66
  await deps.store.removeDisabled(name)
70
67
  return true
@@ -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'