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/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +10 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +132 -94
- package/lib/types/client/api.d.ts +3 -3
- package/lib/types/protocol/config.d.ts +17 -11
- package/lib/types/protocol/market.d.ts +11 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/repo/github-client.d.ts +11 -4
- package/lib/types/repo.d.ts +1 -1
- package/lib/types/routes/deps.d.ts +6 -1
- package/lib/types/skillfs/scan.d.ts +6 -5
- package/lib/types/skillfs.d.ts +25 -1
- package/package.json +21 -20
- package/src/client/api.ts +4 -3
- package/src/client/panel/hooks/useMarketFlow.ts +4 -2
- package/src/client/settings-card.tsx +2 -1
- package/src/client/settings-form.ts +6 -3
- package/src/index.ts +1 -1
- package/src/protocol/config.ts +27 -24
- package/src/protocol/market.ts +12 -0
- package/src/protocol.ts +1 -0
- package/src/repo/github-client.ts +26 -14
- package/src/repo.test.ts +20 -1
- package/src/repo.ts +1 -1
- package/src/routes/catalog-data.ts +7 -4
- package/src/routes/catalog.ts +4 -9
- package/src/routes/deps.ts +7 -2
- package/src/routes/market.ts +13 -2
- package/src/routes/repo-import.ts +4 -7
- package/src/routes.test.ts +68 -0
- package/src/skillfs/scan.ts +30 -17
- package/src/skillfs.test.ts +64 -3
- package/src/skillfs.ts +39 -8
- package/src/update.ts +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-hub",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
117
|
-
"@deepseek-ai/dsh-client-connection": "^0.2.
|
|
118
|
-
"@deepseek-ai/dsh-client-locale": "^0.2.
|
|
119
|
-
"@deepseek-ai/dsh-client-store": "^0.2.
|
|
120
|
-
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.
|
|
121
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.
|
|
122
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.2.
|
|
123
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.
|
|
124
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.2.
|
|
125
|
-
"@deepseek-ai/dsh-host-webserver": "^0.2.
|
|
126
|
-
"@deepseek-ai/dsh-session": "^0.2.
|
|
127
|
-
"@deepseek-ai/dsh-session-query": "^0.2.
|
|
128
|
-
"@deepseek-ai/dsh-settings": "^0.2.
|
|
129
|
-
"@deepseek-ai/dsh-skill": "^0.2.
|
|
130
|
-
"@deepseek-ai/dsh-system-prompt": "^0.2.
|
|
131
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
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<
|
|
161
|
-
return this.post<
|
|
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
|
-
//
|
|
79
|
-
|
|
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 =
|
|
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' &&
|
|
47
|
+
format: (value) => (typeof value === 'string' && HEX_COLOR_RE.test(value) ? value : ''),
|
|
45
48
|
parse: (text) => {
|
|
46
|
-
if (
|
|
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 (
|
|
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(
|
|
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
|
package/src/protocol/config.ts
CHANGED
|
@@ -89,29 +89,30 @@ export const HUB_CONFIG_DEFAULTS = {
|
|
|
89
89
|
} as const
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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(
|
|
99
|
-
const
|
|
100
|
-
const
|
|
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:
|
|
106
|
-
announceToAgent:
|
|
107
|
-
showUseCount:
|
|
108
|
-
showUseTime:
|
|
109
|
-
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
|
-
/**
|
|
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
|
}
|
package/src/protocol/market.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
package/src/routes/catalog.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/src/routes/deps.ts
CHANGED
|
@@ -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(
|
|
46
|
+
return resolveHubConfig(deps.config?.() ?? {})
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
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
|
}
|
package/src/routes/market.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
64
|
-
const
|
|
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
|
package/src/routes.test.ts
CHANGED
|
@@ -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'
|