dsh-skill-hub 0.3.13 → 0.3.15
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 +6 -4
- package/README.md +9 -5
- package/README.zh.md +9 -5
- package/lib/client.js +55 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +529 -315
- package/lib/types/client/SkillHubSettingsCard.d.ts +5 -3
- package/lib/types/client/icons.d.ts +4 -5
- package/lib/types/client/index.d.ts +13 -12
- package/lib/types/client/locales/market.d.ts +1 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/settings-form.d.ts +9 -3
- package/lib/types/client/slash-dots.d.ts +20 -11
- package/lib/types/index.d.ts +59 -24
- package/lib/types/protocol/api.d.ts +22 -2
- package/lib/types/protocol/config.d.ts +30 -4
- package/lib/types/protocol/market.d.ts +1 -1
- package/lib/types/protocol/repo.d.ts +18 -1
- package/lib/types/protocol.d.ts +3 -3
- package/lib/types/repo/discovery.d.ts +59 -3
- package/lib/types/repo/github-client.d.ts +16 -0
- package/lib/types/repo.d.ts +6 -3
- package/lib/types/routes/helpers.d.ts +8 -1
- package/lib/types/routes/market.d.ts +2 -2
- package/lib/types/routes.d.ts +1 -1
- package/lib/types/store/store.d.ts +4 -0
- package/package.json +31 -41
- package/src/client/SkillHubSettingsCard.tsx +5 -3
- package/src/client/icons.tsx +4 -5
- package/src/client/index.tsx +33 -32
- package/src/client/locales/market.ts +2 -0
- package/src/client/panel/RepoScanCard.tsx +6 -3
- package/src/client/panel/SkillHubPanel.tsx +1 -1
- package/src/client/panel/SourcesView.tsx +1 -1
- package/src/client/panel/hooks/useGroupFlow.ts +1 -8
- package/src/client/settings-form.ts +9 -4
- package/src/client/slash-dots.test.ts +19 -36
- package/src/client/slash-dots.tsx +30 -60
- package/src/index.ts +145 -82
- package/src/protocol/api.ts +24 -2
- package/src/protocol/config.ts +35 -4
- package/src/protocol/market.ts +1 -1
- package/src/protocol/repo.ts +22 -1
- package/src/protocol.ts +3 -3
- package/src/repo/discovery.ts +120 -33
- package/src/repo/github-client.ts +18 -1
- package/src/repo/install.ts +8 -7
- package/src/repo.test.ts +158 -4
- package/src/repo.ts +10 -2
- package/src/routes/config.ts +19 -2
- package/src/routes/helpers.ts +9 -2
- package/src/routes/market.ts +49 -37
- package/src/routes/repo-import.ts +3 -2
- package/src/routes/sources.ts +2 -1
- package/src/routes.test.ts +107 -1
- package/src/routes.ts +21 -3
- package/src/store/migrate.ts +3 -2
- package/src/store/store.ts +7 -2
- package/src/store.test.ts +50 -0
- package/src/update.ts +4 -2
package/src/protocol.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 新代码可按需直引 `from './protocol/<domain>.ts'`。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
export { SKILL_HUB_API } from './protocol/api.ts'
|
|
11
|
+
export { SKILL_HUB_API, SKILL_HUB_API_ROOT, SKILL_HUB_API_DEPRECATED_MARKET_CHECK } from './protocol/api.ts'
|
|
12
12
|
export type {
|
|
13
13
|
WritableRoot,
|
|
14
14
|
HubInvocation,
|
|
@@ -30,8 +30,8 @@ export type {
|
|
|
30
30
|
CreateResponse,
|
|
31
31
|
} from './protocol/catalog.ts'
|
|
32
32
|
export type { SkillStat, StatsResponse, SkillStatsCheckpoint } from './protocol/stats.ts'
|
|
33
|
-
export type { ErrorResponse, HubConfig, HubSettingsValue, ConfigResponse, ConfigRequest } from './protocol/config.ts'
|
|
34
|
-
export { HUB_CONFIG_DEFAULTS, HEX_COLOR_RE, GITHUB_TOKEN_RE, resolveHubConfig } from './protocol/config.ts'
|
|
33
|
+
export type { ErrorResponse, HubConfig, HubSettingsValue, RedactedHubConfig, ConfigResponse, ConfigRequest } from './protocol/config.ts'
|
|
34
|
+
export { HUB_CONFIG_DEFAULTS, HUB_ENTRY_ID, HEX_COLOR_RE, GITHUB_TOKEN_RE, resolveHubConfig, redactGithubToken } from './protocol/config.ts'
|
|
35
35
|
export type {
|
|
36
36
|
MarketSourceRecord,
|
|
37
37
|
MarketSourcesResponse,
|
package/src/repo/discovery.ts
CHANGED
|
@@ -2,14 +2,87 @@
|
|
|
2
2
|
* 仓库技能发现:从 GitHub 仓库树推断技能根目录、列出可导入技能、
|
|
3
3
|
* 生成目录清单并做上游差异比对。纯函数为主,便于单测。
|
|
4
4
|
* 从 repo.ts 抽出。
|
|
5
|
+
*
|
|
6
|
+
* 路径语义集中在几个内部函数上:`skillDirPrefix`(技能目录在树里的前缀)、
|
|
7
|
+
* `isRepoTooling`(永不属于技能的仓库基建)、`skillFileAt`(目录内的
|
|
8
|
+
* SKILL.md 路径)。collect / manifest / diff 三处必须共用它们,否则差异
|
|
9
|
+
* 比对会永远报「有更新」。
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
13
|
+
import { REPO_ROOT_RE } from '../protocol/repo.ts'
|
|
8
14
|
import type { RepoRoot, RepoSkillEntry } from '../protocol.ts'
|
|
9
15
|
import type { RepoFile, RepoRef, RepoTreeItem } from './types.ts'
|
|
10
16
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Sentinel root for a skill whose SKILL.md sits directly at the repository
|
|
19
|
+
* root instead of under a top-level directory. That layout is legal and used
|
|
20
|
+
* upstream (a Claude Code plugin manifest may declare `"skills": ["./"]`),
|
|
21
|
+
* and the empty string is the representation that composes with plain prefix
|
|
22
|
+
* arithmetic: an empty prefix *is* the repo root. Every path helper below
|
|
23
|
+
* special-cases it, because `'' + '/' + name` would produce a bogus absolute
|
|
24
|
+
* `/name` that matches no tree path.
|
|
25
|
+
*/
|
|
26
|
+
export const REPO_ROOT: RepoRoot = ''
|
|
27
|
+
|
|
28
|
+
/** True when a root denotes the repo root itself (the repo is the skill dir). */
|
|
29
|
+
export function isRepoRoot(root: string): boolean {
|
|
30
|
+
return root === REPO_ROOT
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The tree prefix delimiting a skill directory: empty for the repo-root skill
|
|
35
|
+
* (it owns the whole tree), otherwise `<dir>/`. Single source for collect,
|
|
36
|
+
* manifest, diff and the store's baseline cleanup, so they can never disagree.
|
|
37
|
+
*/
|
|
38
|
+
export function skillDirPrefix(dir: string): string {
|
|
39
|
+
return isRepoRoot(dir) ? '' : dir + '/'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A path inside a skill directory, as it appears in the repo tree. Both
|
|
44
|
+
* directions of this mapping matter: repo paths are always '/'-joined (never
|
|
45
|
+
* node:path's OS separator, these are GitHub paths shown in the UI), and the
|
|
46
|
+
* repo-root case must not grow a leading slash.
|
|
47
|
+
*/
|
|
48
|
+
export function skillPathIn(dir: string, relative: string): string {
|
|
49
|
+
return skillDirPrefix(dir) + relative
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The SKILL.md path inside a skill directory. */
|
|
53
|
+
export function skillFileAt(dir: string): string {
|
|
54
|
+
return skillPathIn(dir, 'SKILL.md')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Top-level dot entries are repo tooling, never part of a skill: `.github/`,
|
|
59
|
+
* `.claude-plugin/`, `.gitignore`. Only the repo-root skill can reach them,
|
|
60
|
+
* since its prefix is empty and would otherwise sweep in the whole repo.
|
|
61
|
+
* Nested skill dirs cannot start with a dot (ROOT_RE), so this is a no-op
|
|
62
|
+
* for them.
|
|
63
|
+
*/
|
|
64
|
+
function isRepoTooling(path: string): boolean {
|
|
65
|
+
return path.startsWith('.')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every tree blob belonging to a skill directory, sorted by path. */
|
|
69
|
+
function skillTreeFiles(tree: readonly RepoTreeItem[], dir: string): RepoFile[] {
|
|
70
|
+
const prefix = skillDirPrefix(dir)
|
|
71
|
+
return tree
|
|
72
|
+
.filter((item) => item.type === 'blob' && item.path.startsWith(prefix) && !isRepoTooling(item.path))
|
|
73
|
+
.map((item) => ({ path: item.path, size: typeof item.size === 'number' ? item.size : 0 }))
|
|
74
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A repo file's path relative to its skill directory — the path it is written
|
|
79
|
+
* to under the skill's own directory. `dir === ''` means the repo file path
|
|
80
|
+
* *is* the relative path; slicing `dir.length + 1` would eat its first
|
|
81
|
+
* character.
|
|
82
|
+
*/
|
|
83
|
+
export function relativeToSkillDir(dir: string, path: string): string {
|
|
84
|
+
return isRepoRoot(dir) ? path : path.slice(dir.length + 1)
|
|
85
|
+
}
|
|
13
86
|
|
|
14
87
|
/** `owner/repo` slug for a parsed reference. */
|
|
15
88
|
export function repoSlug(ref: RepoRef): string {
|
|
@@ -49,22 +122,32 @@ export function normalizeRepoInput(input: string): RepoRef | null {
|
|
|
49
122
|
|
|
50
123
|
/** Collect every file inside a skill directory, including SKILL.md itself. */
|
|
51
124
|
export function collectRepoSkillFiles(tree: readonly RepoTreeItem[], dir: string): RepoFile[] {
|
|
52
|
-
|
|
53
|
-
return tree
|
|
54
|
-
.filter((item) => item.type === 'blob' && item.path.startsWith(prefix))
|
|
55
|
-
.map((item) => ({ path: item.path, size: typeof item.size === 'number' ? item.size : 0 }))
|
|
56
|
-
.sort((a, b) => a.path.localeCompare(b.path))
|
|
125
|
+
return skillTreeFiles(tree, dir)
|
|
57
126
|
}
|
|
58
127
|
|
|
59
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Compute an origin collection name. Multiple roots split by root, one root
|
|
130
|
+
* keeps the repo slug. The repo-root skill's name *is* the repo slug, so it
|
|
131
|
+
* never gets a trailing-slash suffix (`owner/repo/`).
|
|
132
|
+
*/
|
|
60
133
|
export function originForRoot(repo: string, rootsPresent: ReadonlySet<string>, root: string): string {
|
|
61
|
-
|
|
134
|
+
if (rootsPresent.size <= 1) return repo
|
|
135
|
+
return isRepoRoot(root) ? repo : `${repo}/${root}`
|
|
62
136
|
}
|
|
63
137
|
|
|
64
138
|
/** Discover importable skills from a repo tree. Invalid names are ignored. Roots are auto-derived from the top-level directory of each SKILL.md. */
|
|
65
139
|
export function discoverRepoEntries(tree: readonly RepoTreeItem[], repo: string, existingNames: ReadonlySet<string> = new Set()): RepoSkillEntry[] {
|
|
66
140
|
const candidates: Array<{ root: RepoRoot; dir: string; name: string; path: string }> = []
|
|
67
141
|
const rootsPresent = new Set<string>()
|
|
142
|
+
// Repo-root layout: `SKILL.md` at the very top of the tree, the whole repo
|
|
143
|
+
// being one skill. The name comes from the repo slug; downloadRepoSkill
|
|
144
|
+
// re-checks it against the frontmatter and rejects a mismatch with a 422,
|
|
145
|
+
// so a wrong guess is reported rather than silently mis-installed.
|
|
146
|
+
const repoName = repo.slice(repo.lastIndexOf('/') + 1)
|
|
147
|
+
if (isSkillName(repoName) && tree.some((item) => item.type === 'blob' && item.path === 'SKILL.md')) {
|
|
148
|
+
rootsPresent.add(REPO_ROOT)
|
|
149
|
+
candidates.push({ root: REPO_ROOT, dir: REPO_ROOT, name: repoName, path: 'SKILL.md' })
|
|
150
|
+
}
|
|
68
151
|
for (const item of tree) {
|
|
69
152
|
if (item.type !== 'blob') continue
|
|
70
153
|
// Any SKILL.md at depth >=2: top segment is the root, last segment is the skill name (may be nested like root/category/name/SKILL.md)
|
|
@@ -72,7 +155,7 @@ export function discoverRepoEntries(tree: readonly RepoTreeItem[], repo: string,
|
|
|
72
155
|
if (slash === -1) continue
|
|
73
156
|
if (!item.path.endsWith('/SKILL.md')) continue
|
|
74
157
|
const root = item.path.slice(0, slash)
|
|
75
|
-
if (!
|
|
158
|
+
if (!REPO_ROOT_RE.test(root)) continue
|
|
76
159
|
const dir = item.path.slice(0, -'/SKILL.md'.length)
|
|
77
160
|
// dir must be at least root/name (reject bare root/SKILL.md)
|
|
78
161
|
if (dir === root || dir.length <= root.length + 1) continue
|
|
@@ -98,15 +181,15 @@ export function discoverRepoEntries(tree: readonly RepoTreeItem[], repo: string,
|
|
|
98
181
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
99
182
|
}
|
|
100
183
|
|
|
101
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Build a path→size manifest for one skill directory from a repo tree.
|
|
186
|
+
* Shares `skillTreeFiles` with collectRepoSkillFiles: a manifest that
|
|
187
|
+
* disagreed with the collected file set would make every update diff report
|
|
188
|
+
* "changed" forever.
|
|
189
|
+
*/
|
|
102
190
|
export function skillManifest(tree: readonly RepoTreeItem[], dir: string): Record<string, number> {
|
|
103
|
-
const prefix = dir + '/'
|
|
104
191
|
const manifest: Record<string, number> = {}
|
|
105
|
-
for (const
|
|
106
|
-
if (item.type === 'blob' && item.path.startsWith(prefix)) {
|
|
107
|
-
manifest[item.path] = typeof item.size === 'number' ? item.size : 0
|
|
108
|
-
}
|
|
109
|
-
}
|
|
192
|
+
for (const file of skillTreeFiles(tree, dir)) manifest[file.path] = file.size
|
|
110
193
|
return manifest
|
|
111
194
|
}
|
|
112
195
|
|
|
@@ -119,12 +202,19 @@ export function skillManifest(tree: readonly RepoTreeItem[], dir: string): Recor
|
|
|
119
202
|
* manifest's recorded blob path, then any same-name skill in the tree, then
|
|
120
203
|
* the flat root/name fallback. Passing the tree paths makes the lookup
|
|
121
204
|
* resilient to incomplete manifests and upstream moves.
|
|
205
|
+
*
|
|
206
|
+
* A repo-root record (root '') has no directory to resolve: its SKILL.md is a
|
|
207
|
+
* bare top-level path, so every nested lookup below would miss and the final
|
|
208
|
+
* fallback would hand back a bogus '/name'. Answer before them — including
|
|
209
|
+
* when the upstream SKILL.md is gone, so diffRemoteSkills reports a deletion
|
|
210
|
+
* instead of hunting for a skill that was never nested.
|
|
122
211
|
*/
|
|
123
212
|
export function skillDirOf(
|
|
124
213
|
source: { root: string; manifest?: Record<string, number> },
|
|
125
214
|
name: string,
|
|
126
215
|
treePaths?: readonly string[],
|
|
127
216
|
): string {
|
|
217
|
+
if (isRepoRoot(source.root)) return REPO_ROOT
|
|
128
218
|
if (treePaths !== undefined) {
|
|
129
219
|
const rootPrefix = source.root + '/'
|
|
130
220
|
const found = treePaths.find((path) => path.startsWith(rootPrefix) && path.endsWith('/' + name + '/SKILL.md'))
|
|
@@ -144,38 +234,34 @@ export function skillDirOf(
|
|
|
144
234
|
* Diff one source record against an upstream tree at treeSha: which tracked
|
|
145
235
|
* skills disappeared (no SKILL.md blob) and which changed (manifest baseline
|
|
146
236
|
* differs, or no baseline exists — treated as changed). Pure over the tree.
|
|
237
|
+
* The remote side is read through skillManifest, so the diff compares like
|
|
238
|
+
* with like against the baseline recorded at import time.
|
|
147
239
|
*/
|
|
148
240
|
export function diffRemoteSkills(
|
|
149
241
|
tree: readonly RepoTreeItem[],
|
|
150
242
|
source: { root: string; skills: readonly string[]; manifest?: Record<string, number> },
|
|
151
243
|
): { updated: string[]; deleted: string[] } {
|
|
152
|
-
const
|
|
153
|
-
for (const item of tree) {
|
|
154
|
-
if (item.type === 'blob') blobs.set(item.path, typeof item.size === 'number' ? item.size : 0)
|
|
155
|
-
}
|
|
156
|
-
const treePaths = [...blobs.keys()]
|
|
244
|
+
const treePaths = tree.filter((item) => item.type === 'blob').map((item) => item.path)
|
|
157
245
|
const updated: string[] = []
|
|
158
246
|
const deleted: string[] = []
|
|
247
|
+
const baseline = source.manifest ?? {}
|
|
159
248
|
for (const name of source.skills) {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
if (!remote.has(prefix + 'SKILL.md')) {
|
|
249
|
+
const dir = skillDirOf(source, name, treePaths)
|
|
250
|
+
const prefix = skillDirPrefix(dir)
|
|
251
|
+
const remote = skillManifest(tree, dir)
|
|
252
|
+
if (!Object.hasOwn(remote, skillFileAt(dir))) {
|
|
166
253
|
deleted.push(name)
|
|
167
254
|
continue
|
|
168
255
|
}
|
|
169
|
-
const baseline = source.manifest ?? {}
|
|
170
256
|
const baselineEntries = Object.entries(baseline).filter(([path]) => path.startsWith(prefix))
|
|
171
257
|
if (baselineEntries.length === 0) {
|
|
172
258
|
updated.push(name) // no baseline (migrated/legacy import): treat as changed
|
|
173
259
|
continue
|
|
174
260
|
}
|
|
175
|
-
let differs = baselineEntries.length !== remote.
|
|
261
|
+
let differs = baselineEntries.length !== Object.keys(remote).length
|
|
176
262
|
if (!differs) {
|
|
177
263
|
for (const [path, size] of baselineEntries) {
|
|
178
|
-
if (remote
|
|
264
|
+
if (remote[path] !== size) {
|
|
179
265
|
differs = true
|
|
180
266
|
break
|
|
181
267
|
}
|
|
@@ -188,10 +274,11 @@ export function diffRemoteSkills(
|
|
|
188
274
|
|
|
189
275
|
/** Minimal RepoSkillEntry for a tracked skill name (sync re-downloads by name). */
|
|
190
276
|
export function repoSkillEntry(name: string, root: string, repo: string): RepoSkillEntry {
|
|
277
|
+
const dir = isRepoRoot(root) ? REPO_ROOT : root + '/' + name
|
|
191
278
|
return {
|
|
192
279
|
name,
|
|
193
|
-
dir
|
|
194
|
-
path:
|
|
280
|
+
dir,
|
|
281
|
+
path: skillFileAt(dir),
|
|
195
282
|
root,
|
|
196
283
|
origin: repo,
|
|
197
284
|
fileCount: 0,
|
|
@@ -34,9 +34,26 @@ export function githubAuthHeaders(): Record<string, string> {
|
|
|
34
34
|
return githubToken === '' ? {} : { authorization: 'Bearer ' + githubToken }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 传输层:显式要求服务端返回未压缩实体。
|
|
39
|
+
*
|
|
40
|
+
* 为什么必须加:dsh 会把**启动环境**里的 `HTTP_PROXY`/`HTTPS_PROXY` 装成
|
|
41
|
+
* undici 的全局 dispatcher(`@deepseek-ai/dsh-http-proxy`),插件里的 `fetch`
|
|
42
|
+
* 因此走该代理。实测本机 Clash:经代理返回的响应会**丢掉 `content-encoding`
|
|
43
|
+
* 与 `content-type` 头,而 body 仍是 gzip**;undici 只依据 `content-encoding`
|
|
44
|
+
* 决定是否解压,于是 `response.json()` 拿到 gzip 二进制并抛错,被上层的
|
|
45
|
+
* catch 映射成 `invalid github response for <url>` —— 看起来像 GitHub 坏了。
|
|
46
|
+
* 实测对照(同一 URL、同一代理):默认 1331 字节解析失败,声明 identity 后
|
|
47
|
+
* 5245 字节解析成功。
|
|
48
|
+
*
|
|
49
|
+
* 代价是不走压缩(我们的响应体都很小),换来的是**无论中间代理是否改写头部
|
|
50
|
+
* 都能正确取到实体**,且用户无需为插件调整启动命令。
|
|
51
|
+
*/
|
|
52
|
+
export const NO_COMPRESSION: Record<string, string> = { 'accept-encoding': 'identity' }
|
|
53
|
+
|
|
37
54
|
/** JSON API 默认请求头(含鉴权)。 */
|
|
38
55
|
export function apiHeaders(): Record<string, string> {
|
|
39
|
-
return { accept: 'application/vnd.github+json', ...githubAuthHeaders() }
|
|
56
|
+
return { accept: 'application/vnd.github+json', ...NO_COMPRESSION, ...githubAuthHeaders() }
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
/** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
|
package/src/repo/install.ts
CHANGED
|
@@ -10,7 +10,8 @@ import type { RepoSkillEntry } from '../protocol.ts'
|
|
|
10
10
|
import { errorText } from '../error-text.ts'
|
|
11
11
|
import { mapConcurrent } from '../concurrency.ts'
|
|
12
12
|
import { parseFrontmatter } from '../skillfs.ts'
|
|
13
|
-
import { RepoFetchError, fetchError, githubAuthHeaders, isAbortError } from './github-client.ts'
|
|
13
|
+
import { NO_COMPRESSION, RepoFetchError, fetchError, githubAuthHeaders, isAbortError } from './github-client.ts'
|
|
14
|
+
import { relativeToSkillDir } from './discovery.ts'
|
|
14
15
|
import type { RepoFile } from './types.ts'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -26,7 +27,7 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
|
|
|
26
27
|
let firstError: string | null = null
|
|
27
28
|
let response: Response | null = null
|
|
28
29
|
try {
|
|
29
|
-
response = await fetchImpl(rawUrl, { headers: githubAuthHeaders(), ...(signal !== undefined ? { signal } : {}) })
|
|
30
|
+
response = await fetchImpl(rawUrl, { headers: { ...NO_COMPRESSION, ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
|
|
30
31
|
} catch (error) {
|
|
31
32
|
if (isAbortError(error)) throw error
|
|
32
33
|
firstError = errorText(error)
|
|
@@ -36,7 +37,7 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
|
|
|
36
37
|
// Fallback: api.github.com/contents with the raw media type.
|
|
37
38
|
const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`
|
|
38
39
|
try {
|
|
39
|
-
response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
|
|
40
|
+
response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...NO_COMPRESSION, ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
|
|
40
41
|
} catch (error) {
|
|
41
42
|
if (isAbortError(error)) throw error
|
|
42
43
|
throw new RepoFetchError('download failed: ' + (firstError ?? (errorText(error))))
|
|
@@ -78,7 +79,7 @@ export async function downloadRepoSkill(
|
|
|
78
79
|
try {
|
|
79
80
|
await mapConcurrent(files, 6, async (file) => {
|
|
80
81
|
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
81
|
-
const relative =
|
|
82
|
+
const relative = relativeToSkillDir(entry.dir, file.path)
|
|
82
83
|
if (relative === '' || relative.includes('..')) throw new RepoFetchError('unsafe repo path: ' + file.path)
|
|
83
84
|
const target = join(tempDir, relative)
|
|
84
85
|
const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal)
|
|
@@ -115,7 +116,7 @@ export async function downloadRepoSkill(
|
|
|
115
116
|
try {
|
|
116
117
|
await rm(tempDir, { recursive: true, force: true })
|
|
117
118
|
} catch (secondError) {
|
|
118
|
-
console.warn(`[skill-hub] cleanup tempDir failed ${tempDir}:`, errorText(secondError), 'first:', errorText(firstError))
|
|
119
|
+
console.warn(`[dsh-skill-hub] cleanup tempDir failed ${tempDir}:`, errorText(secondError), 'first:', errorText(firstError))
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
}
|
|
@@ -142,9 +143,9 @@ export async function cleanupLeftoverImportDirs(targetRoot: string): Promise<num
|
|
|
142
143
|
await rm2(full, { recursive: true, force: true })
|
|
143
144
|
cleaned += 1
|
|
144
145
|
} catch (error) {
|
|
145
|
-
console.warn(`[skill-hub] startup cleanup failed ${full}:`, errorText(error))
|
|
146
|
+
console.warn(`[dsh-skill-hub] startup cleanup failed ${full}:`, errorText(error))
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
|
-
if (cleaned > 0) console.warn(`[skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`)
|
|
149
|
+
if (cleaned > 0) console.warn(`[dsh-skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`)
|
|
149
150
|
return cleaned
|
|
150
151
|
}
|
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 { collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, repoSlug, skillDirOf } from './repo.ts'
|
|
5
|
+
import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, 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
|
|
|
@@ -62,6 +62,26 @@ describe('collectRepoSkillFiles', () => {
|
|
|
62
62
|
]
|
|
63
63
|
expect(collectRepoSkillFiles(tree, 'skills/code-review').map((file) => file.path)).toEqual(['skills/code-review/README.md', 'skills/code-review/SKILL.md'])
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
it('collects the whole repo for the repo-root skill, minus repo tooling', () => {
|
|
67
|
+
const tree = [
|
|
68
|
+
blob('SKILL.md', 900),
|
|
69
|
+
blob('AGENTS.md', 10),
|
|
70
|
+
blob('LICENSE', 20),
|
|
71
|
+
blob('README.md', 30),
|
|
72
|
+
blob('agents/openai.yaml', 5),
|
|
73
|
+
blob('scripts/validate.py', 6),
|
|
74
|
+
blob('.github/workflows/validate.yml', 999),
|
|
75
|
+
blob('.claude-plugin/plugin.json', 999),
|
|
76
|
+
blob('.gitignore', 999),
|
|
77
|
+
]
|
|
78
|
+
const files = collectRepoSkillFiles(tree, '')
|
|
79
|
+
expect(files.map((file) => file.path)).toEqual(['AGENTS.md', 'agents/openai.yaml', 'LICENSE', 'README.md', 'scripts/validate.py', 'SKILL.md'])
|
|
80
|
+
expect(files.reduce((sum, file) => sum + file.size, 0)).toBe(971)
|
|
81
|
+
// The manifest must describe exactly the collected set, or every later
|
|
82
|
+
// update diff would report "changed".
|
|
83
|
+
expect(skillManifest(tree, '')).toEqual(Object.fromEntries(files.map((file) => [file.path, file.size])))
|
|
84
|
+
})
|
|
65
85
|
})
|
|
66
86
|
|
|
67
87
|
describe('discoverRepoEntries', () => {
|
|
@@ -100,16 +120,54 @@ describe('discoverRepoEntries', () => {
|
|
|
100
120
|
expect(entries.map((e) => e.name).sort()).toEqual(['deploy', 'foo', 'my-template'])
|
|
101
121
|
})
|
|
102
122
|
|
|
103
|
-
it('ignores hidden roots and bare SKILL.md
|
|
123
|
+
it('ignores hidden roots and bare root/SKILL.md', () => {
|
|
104
124
|
const tree = [
|
|
105
125
|
blob('.hidden/secret/SKILL.md', 1),
|
|
106
|
-
blob('SKILL.md', 1),
|
|
107
126
|
blob('skills/SKILL.md', 1),
|
|
108
127
|
blob('.github/workflows/ci/SKILL.md', 1),
|
|
109
128
|
]
|
|
110
129
|
expect(discoverRepoEntries(tree, 'a/b')).toEqual([])
|
|
111
130
|
})
|
|
112
131
|
|
|
132
|
+
it('discovers a SKILL.md at the repo root as one skill named after the repo', () => {
|
|
133
|
+
// The whole-repo layout (a plugin manifest may declare "skills": ["./"]).
|
|
134
|
+
const tree = [
|
|
135
|
+
blob('SKILL.md', 900),
|
|
136
|
+
blob('AGENTS.md', 10),
|
|
137
|
+
blob('LICENSE', 20),
|
|
138
|
+
blob('README.md', 30),
|
|
139
|
+
blob('agents/openai.yaml', 5),
|
|
140
|
+
blob('scripts/validate.py', 6),
|
|
141
|
+
blob('.github/workflows/validate.yml', 999),
|
|
142
|
+
blob('.claude-plugin/plugin.json', 999),
|
|
143
|
+
blob('.gitignore', 999),
|
|
144
|
+
]
|
|
145
|
+
const entries = discoverRepoEntries(tree, 'blader/humanizer')
|
|
146
|
+
expect(entries).toHaveLength(1)
|
|
147
|
+
const entry = entries[0]
|
|
148
|
+
expect(entry.name).toBe('humanizer')
|
|
149
|
+
expect(entry.root).toBe('')
|
|
150
|
+
expect(entry.dir).toBe('')
|
|
151
|
+
expect(entry.path).toBe('SKILL.md')
|
|
152
|
+
// Repo tooling (top-level dot entries) is not part of the skill.
|
|
153
|
+
expect(entry.fileCount).toBe(6)
|
|
154
|
+
expect(entry.totalBytes).toBe(971)
|
|
155
|
+
expect(entry.origin).toBe('blader/humanizer')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('splits the repo-root origin from nested roots without a trailing slash', () => {
|
|
159
|
+
const tree = [blob('SKILL.md', 1), blob('skills/foo/SKILL.md', 1)]
|
|
160
|
+
const entries = discoverRepoEntries(tree, 'a/b')
|
|
161
|
+
expect(entries.map((e) => [e.name, e.root, e.origin])).toEqual([
|
|
162
|
+
['b', '', 'a/b'],
|
|
163
|
+
['foo', 'skills', 'a/b/skills'],
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('skips the repo-root candidate when the repo name is not a valid skill name', () => {
|
|
168
|
+
expect(discoverRepoEntries([blob('SKILL.md', 1)], 'a/Not A Skill')).toEqual([])
|
|
169
|
+
})
|
|
170
|
+
|
|
113
171
|
it('returns empty when no SKILL.md exists', () => {
|
|
114
172
|
expect(discoverRepoEntries([blob('README.md'), blob('skills/foo/README.md')], 'a/b')).toEqual([])
|
|
115
173
|
})
|
|
@@ -187,6 +245,68 @@ describe('diffRemoteSkills', () => {
|
|
|
187
245
|
})
|
|
188
246
|
})
|
|
189
247
|
|
|
248
|
+
describe('repo-root skills (SKILL.md at the repository root)', () => {
|
|
249
|
+
it('maps paths without a leading slash or an over-eager slice', () => {
|
|
250
|
+
// The prefix is empty, so relative === absolute and slicing would eat a char.
|
|
251
|
+
expect(skillFileAt('')).toBe('SKILL.md')
|
|
252
|
+
expect(relativeToSkillDir('', 'SKILL.md')).toBe('SKILL.md')
|
|
253
|
+
expect(relativeToSkillDir('', 'agents/openai.yaml')).toBe('agents/openai.yaml')
|
|
254
|
+
// Nested dirs are unchanged.
|
|
255
|
+
expect(skillFileAt('skills/demo')).toBe('skills/demo/SKILL.md')
|
|
256
|
+
expect(relativeToSkillDir('skills/demo', 'skills/demo/SKILL.md')).toBe('SKILL.md')
|
|
257
|
+
expect(relativeToSkillDir('skills/demo', 'skills/demo/agents/a.yaml')).toBe('agents/a.yaml')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('builds a repo-root entry whose dir is empty and whose path is a bare SKILL.md', () => {
|
|
261
|
+
expect(repoSkillEntry('humanizer', '', 'blader/humanizer')).toEqual({
|
|
262
|
+
name: 'humanizer',
|
|
263
|
+
dir: '',
|
|
264
|
+
path: 'SKILL.md',
|
|
265
|
+
root: '',
|
|
266
|
+
origin: 'blader/humanizer',
|
|
267
|
+
fileCount: 0,
|
|
268
|
+
totalBytes: 0,
|
|
269
|
+
existing: false,
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('resolves the skill directory to the repo root, never to "/name"', () => {
|
|
274
|
+
const manifest = { 'SKILL.md': 900, 'README.md': 30 }
|
|
275
|
+
expect(skillDirOf({ root: '', manifest }, 'humanizer')).toBe('')
|
|
276
|
+
// A tree lookup must not drag it into a same-name nested skill.
|
|
277
|
+
expect(skillDirOf({ root: '' }, 'humanizer', ['SKILL.md', 'skills/humanizer/SKILL.md'])).toBe('')
|
|
278
|
+
// Upstream deleted the SKILL.md: still '', so the diff reports a deletion
|
|
279
|
+
// instead of hunting for a nested directory that never existed.
|
|
280
|
+
expect(skillDirOf({ root: '', manifest }, 'humanizer', ['README.md'])).toBe('')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('does not mistake an unchanged repo-root skill for a deletion', () => {
|
|
284
|
+
// Regression guard for the mid-upgrade hazard: with the old fallback
|
|
285
|
+
// (root + '/' + name) the prefix was '/humanizer/', matched no blob, and
|
|
286
|
+
// the skill was reported as deleted — which the GUI turns into a move to
|
|
287
|
+
// the trash can, even though upstream never changed.
|
|
288
|
+
const source = { root: '', skills: ['humanizer'], manifest: { 'SKILL.md': 900, 'README.md': 30, 'agents/openai.yaml': 5 } }
|
|
289
|
+
const tree = [blob('SKILL.md', 900), blob('README.md', 30), blob('agents/openai.yaml', 5), blob('.github/workflows/ci.yml', 999)]
|
|
290
|
+
expect(diffRemoteSkills(tree, source)).toEqual({ updated: [], deleted: [] })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('reports repo-root updates and deletions', () => {
|
|
294
|
+
const source = { root: '', skills: ['humanizer'], manifest: { 'SKILL.md': 900, 'README.md': 30 } }
|
|
295
|
+
expect(diffRemoteSkills([blob('SKILL.md', 1200), blob('README.md', 30)], source)).toEqual({ updated: ['humanizer'], deleted: [] })
|
|
296
|
+
// A file removed upstream changes the manifest without being a deletion.
|
|
297
|
+
expect(diffRemoteSkills([blob('SKILL.md', 900)], source)).toEqual({ updated: ['humanizer'], deleted: [] })
|
|
298
|
+
// No SKILL.md at all is a real deletion.
|
|
299
|
+
expect(diffRemoteSkills([blob('README.md', 30)], source)).toEqual({ updated: [], deleted: ['humanizer'] })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('treats repo tooling as neither an addition nor a change', () => {
|
|
303
|
+
// A .github-only upstream change must not flip the skill to "updated".
|
|
304
|
+
const source = { root: '', skills: ['humanizer'], manifest: { 'SKILL.md': 900 } }
|
|
305
|
+
const tree = [blob('SKILL.md', 900), blob('.github/workflows/ci.yml', 1)]
|
|
306
|
+
expect(diffRemoteSkills(tree, source)).toEqual({ updated: [], deleted: [] })
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
190
310
|
describe('downloadRepoSkill', () => {
|
|
191
311
|
it('creates the target root when missing and imports a skill directory', async () => {
|
|
192
312
|
const parent = await mkdtemp(join(tmpdir(), 'dsh-repo-download-'))
|
|
@@ -211,12 +331,32 @@ describe('downloadRepoSkill', () => {
|
|
|
211
331
|
await rm(parent, { recursive: true, force: true })
|
|
212
332
|
}
|
|
213
333
|
})
|
|
334
|
+
|
|
335
|
+
it('imports a repo-root skill keeping its top-level paths intact', async () => {
|
|
336
|
+
const parent = await mkdtemp(join(tmpdir(), 'dsh-repo-download-root-'))
|
|
337
|
+
try {
|
|
338
|
+
const targetRoot = join(parent, 'skills')
|
|
339
|
+
const entry: RepoSkillEntry = repoSkillEntry('humanizer', '', 'blader/humanizer')
|
|
340
|
+
const files = [
|
|
341
|
+
{ path: 'SKILL.md', size: 1 },
|
|
342
|
+
{ path: 'agents/openai.yaml', size: 1 },
|
|
343
|
+
]
|
|
344
|
+
const fetchImpl = async () => new Response('---\nname: humanizer\ndescription: Rewrite AI-sounding text\n---\n\nbody', { status: 200 })
|
|
345
|
+
const result = await downloadRepoSkill('blader/humanizer', 'main', entry, files, targetRoot, fetchImpl as typeof fetch)
|
|
346
|
+
expect(result.skillPath).toBe(join(targetRoot, 'humanizer', 'SKILL.md'))
|
|
347
|
+
// 'SKILL.md' must not be sliced down to 'KILL.md'.
|
|
348
|
+
await expect(readFile(join(targetRoot, 'humanizer', 'SKILL.md'), 'utf8')).resolves.toContain('name: humanizer')
|
|
349
|
+
await expect(readFile(join(targetRoot, 'humanizer', 'agents', 'openai.yaml'), 'utf8')).resolves.toContain('name: humanizer')
|
|
350
|
+
} finally {
|
|
351
|
+
await rm(parent, { recursive: true, force: true })
|
|
352
|
+
}
|
|
353
|
+
})
|
|
214
354
|
})
|
|
215
355
|
|
|
216
356
|
describe('downloadGitHubFile', () => {
|
|
217
357
|
it('falls back to the api contents endpoint when raw is unreachable', async () => {
|
|
218
358
|
const calls: string[] = []
|
|
219
|
-
const fetchImpl = async (url: string
|
|
359
|
+
const fetchImpl = async (url: string) => {
|
|
220
360
|
calls.push(url)
|
|
221
361
|
if (url.startsWith('https://raw.githubusercontent.com/')) throw new Error('raw blocked')
|
|
222
362
|
if (url.includes('/contents/')) return new Response('---\nname: demo\ndescription: x\n---\n\nbody', { status: 200 })
|
|
@@ -275,3 +415,17 @@ describe('getRepoStats', () => {
|
|
|
275
415
|
expect(await getRepoStats('a/b', fetchImpl as typeof fetch)).toEqual({ stars: 7, downloads: 0 })
|
|
276
416
|
})
|
|
277
417
|
})
|
|
418
|
+
|
|
419
|
+
describe('GitHub 请求头', () => {
|
|
420
|
+
it('总是要求未压缩实体', () => {
|
|
421
|
+
// 经代理的响应会丢掉 content-encoding 但 body 仍是 gzip(见 github-client.ts
|
|
422
|
+
// 里 NO_COMPRESSION 的说明),一旦漏掉这个头,所有 GitHub 调用会假性失败并
|
|
423
|
+
// 报 `invalid github response for <url>`。
|
|
424
|
+
expect(NO_COMPRESSION['accept-encoding']).toBe('identity')
|
|
425
|
+
expect(apiHeaders()['accept-encoding']).toBe('identity')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('保留 JSON 的 accept 头', () => {
|
|
429
|
+
expect(apiHeaders().accept).toBe('application/vnd.github+json')
|
|
430
|
+
})
|
|
431
|
+
})
|
package/src/repo.ts
CHANGED
|
@@ -4,18 +4,23 @@
|
|
|
4
4
|
* Kept dependency-free and mostly pure so the root/origin rules are easy to
|
|
5
5
|
* test. Roots are auto-derived: any top-level directory that contains a
|
|
6
6
|
* `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
|
|
7
|
-
* `workflows/**`) is treated as a skill root. No hard-coded allowlist.
|
|
7
|
+
* `workflows/**`) is treated as a skill root. No hard-coded allowlist. A
|
|
8
|
+
* `SKILL.md` at the repo root is the empty-string root (`REPO_ROOT`): the
|
|
9
|
+
* whole repo is one skill, named after the repo, with top-level dot entries
|
|
10
|
+
* treated as repo tooling rather than skill content.
|
|
8
11
|
*
|
|
9
12
|
* The implementation now lives under `./repo/` (types, discovery, api,
|
|
10
13
|
* install) and this module is a barrel that re-exports it.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
// 后向兼容:老 `from './repo.ts'` 写法继续可用,新代码可直引 github-client。
|
|
14
|
-
export { RepoFetchError, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts'
|
|
17
|
+
export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts'
|
|
15
18
|
|
|
16
19
|
export type { RepoRef, RepoTreeItem, RepoFile } from './repo/types.ts'
|
|
17
20
|
|
|
18
21
|
export {
|
|
22
|
+
REPO_ROOT,
|
|
23
|
+
isRepoRoot,
|
|
19
24
|
repoSlug,
|
|
20
25
|
normalizeRepoInput,
|
|
21
26
|
collectRepoSkillFiles,
|
|
@@ -23,6 +28,9 @@ export {
|
|
|
23
28
|
discoverRepoEntries,
|
|
24
29
|
skillManifest,
|
|
25
30
|
skillDirOf,
|
|
31
|
+
skillFileAt,
|
|
32
|
+
skillPathIn,
|
|
33
|
+
relativeToSkillDir,
|
|
26
34
|
diffRemoteSkills,
|
|
27
35
|
repoSkillEntry,
|
|
28
36
|
} from './repo/discovery.ts'
|
package/src/routes/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
SKILL_HUB_API,
|
|
8
8
|
GITHUB_TOKEN_RE,
|
|
9
9
|
HEX_COLOR_RE,
|
|
10
|
+
redactGithubToken,
|
|
10
11
|
type ConfigResponse,
|
|
11
12
|
type DiagnosticFixRequest,
|
|
12
13
|
type DiagnosticFixResponse,
|
|
@@ -25,6 +26,22 @@ import {
|
|
|
25
26
|
type SkillHubRouteDeps,
|
|
26
27
|
} from './helpers.ts'
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Build a config payload for either method. The GitHub token never goes out:
|
|
31
|
+
* the effective config and the raw saved overrides are both stripped, and its
|
|
32
|
+
* presence is reported as a plain boolean instead.
|
|
33
|
+
*/
|
|
34
|
+
function configResponse(config: HubConfig, saved: Partial<HubConfig>): ConfigResponse {
|
|
35
|
+
const token = config.githubToken
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
pluginVersion: CURRENT_VERSION,
|
|
39
|
+
config: redactGithubToken(config),
|
|
40
|
+
saved: redactGithubToken(saved),
|
|
41
|
+
githubTokenSet: token !== undefined && token !== '',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
/** 配置域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
29
46
|
export function configRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
30
47
|
return [
|
|
@@ -38,7 +55,7 @@ export function configRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
38
55
|
skipGate: true,
|
|
39
56
|
handler: async ({ req, res, body }) => {
|
|
40
57
|
if (req.method === 'GET') {
|
|
41
|
-
writeJson(res, 200,
|
|
58
|
+
writeJson(res, 200, configResponse(configOf(deps), savedOf(deps)))
|
|
42
59
|
return
|
|
43
60
|
}
|
|
44
61
|
const raw = body
|
|
@@ -85,7 +102,7 @@ export function configRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
85
102
|
} else {
|
|
86
103
|
config = await deps.updateConfig(patch)
|
|
87
104
|
}
|
|
88
|
-
writeJson(res, 200,
|
|
105
|
+
writeJson(res, 200, configResponse(config, savedOf(deps)))
|
|
89
106
|
},
|
|
90
107
|
},
|
|
91
108
|
// -------------------------------------------------------------- update
|