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.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +6 -4
  2. package/README.md +9 -5
  3. package/README.zh.md +9 -5
  4. package/lib/client.js +55 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +529 -315
  7. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -3
  8. package/lib/types/client/icons.d.ts +4 -5
  9. package/lib/types/client/index.d.ts +13 -12
  10. package/lib/types/client/locales/market.d.ts +1 -0
  11. package/lib/types/client/locales.d.ts +1 -0
  12. package/lib/types/client/settings-form.d.ts +9 -3
  13. package/lib/types/client/slash-dots.d.ts +20 -11
  14. package/lib/types/index.d.ts +59 -24
  15. package/lib/types/protocol/api.d.ts +22 -2
  16. package/lib/types/protocol/config.d.ts +30 -4
  17. package/lib/types/protocol/market.d.ts +1 -1
  18. package/lib/types/protocol/repo.d.ts +18 -1
  19. package/lib/types/protocol.d.ts +3 -3
  20. package/lib/types/repo/discovery.d.ts +59 -3
  21. package/lib/types/repo/github-client.d.ts +16 -0
  22. package/lib/types/repo.d.ts +6 -3
  23. package/lib/types/routes/helpers.d.ts +8 -1
  24. package/lib/types/routes/market.d.ts +2 -2
  25. package/lib/types/routes.d.ts +1 -1
  26. package/lib/types/store/store.d.ts +4 -0
  27. package/package.json +31 -41
  28. package/src/client/SkillHubSettingsCard.tsx +5 -3
  29. package/src/client/icons.tsx +4 -5
  30. package/src/client/index.tsx +33 -32
  31. package/src/client/locales/market.ts +2 -0
  32. package/src/client/panel/RepoScanCard.tsx +6 -3
  33. package/src/client/panel/SkillHubPanel.tsx +1 -1
  34. package/src/client/panel/SourcesView.tsx +1 -1
  35. package/src/client/panel/hooks/useGroupFlow.ts +1 -8
  36. package/src/client/settings-form.ts +9 -4
  37. package/src/client/slash-dots.test.ts +19 -36
  38. package/src/client/slash-dots.tsx +30 -60
  39. package/src/index.ts +145 -82
  40. package/src/protocol/api.ts +24 -2
  41. package/src/protocol/config.ts +35 -4
  42. package/src/protocol/market.ts +1 -1
  43. package/src/protocol/repo.ts +22 -1
  44. package/src/protocol.ts +3 -3
  45. package/src/repo/discovery.ts +120 -33
  46. package/src/repo/github-client.ts +18 -1
  47. package/src/repo/install.ts +8 -7
  48. package/src/repo.test.ts +158 -4
  49. package/src/repo.ts +10 -2
  50. package/src/routes/config.ts +19 -2
  51. package/src/routes/helpers.ts +9 -2
  52. package/src/routes/market.ts +49 -37
  53. package/src/routes/repo-import.ts +3 -2
  54. package/src/routes/sources.ts +2 -1
  55. package/src/routes.test.ts +107 -1
  56. package/src/routes.ts +21 -3
  57. package/src/store/migrate.ts +3 -2
  58. package/src/store/store.ts +7 -2
  59. package/src/store.test.ts +50 -0
  60. 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,
@@ -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
- /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
12
- const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
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
- const prefix = dir + '/'
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
- /** Compute an origin collection name. Multiple roots split by root, one root keeps the repo slug. */
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
- return rootsPresent.size > 1 ? `${repo}/${root}` : repo
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 (!ROOT_RE.test(root)) continue
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
- /** Build a path→size manifest for one skill directory from a repo tree. */
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 item of tree) {
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 blobs = new Map<string, number>()
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 prefix = skillDirOf(source, name, treePaths) + '/'
161
- const remote = new Map<string, number>()
162
- for (const [path, size] of blobs) {
163
- if (path.startsWith(prefix)) remote.set(path, size)
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.size
261
+ let differs = baselineEntries.length !== Object.keys(remote).length
176
262
  if (!differs) {
177
263
  for (const [path, size] of baselineEntries) {
178
- if (remote.get(path) !== size) {
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: root + '/' + name,
194
- path: root + '/' + name + '/SKILL.md',
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. */
@@ -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 = file.path.slice(entry.dir.length + 1)
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 at depth 1', () => {
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, init?: RequestInit) => {
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'
@@ -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, { ok: true, pluginVersion: CURRENT_VERSION, config: configOf(deps), saved: savedOf(deps) } satisfies ConfigResponse)
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, { ok: true, pluginVersion: CURRENT_VERSION, config, saved: savedOf(deps) } satisfies ConfigResponse)
105
+ writeJson(res, 200, configResponse(config, savedOf(deps)))
89
106
  },
90
107
  },
91
108
  // -------------------------------------------------------------- update