dsh-skill-hub 0.3.27 → 0.3.28

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 (70) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +10 -2
  3. package/README.zh.md +10 -2
  4. package/lib/client.js +310 -39
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +790 -15
  7. package/lib/types/client/api.d.ts +8 -1
  8. package/lib/types/client/locales/common.d.ts +4 -0
  9. package/lib/types/client/locales/market.d.ts +10 -0
  10. package/lib/types/client/locales/sources.d.ts +3 -0
  11. package/lib/types/client/locales.d.ts +17 -0
  12. package/lib/types/client/panel/ForeignImportDialog.d.ts +15 -0
  13. package/lib/types/client/panel/PanelDialogs.d.ts +2 -0
  14. package/lib/types/client/panel/dialogs.d.ts +11 -1
  15. package/lib/types/client/panel/useSkillHub.d.ts +1 -0
  16. package/lib/types/protocol/api.d.ts +2 -0
  17. package/lib/types/protocol/catalog.d.ts +39 -1
  18. package/lib/types/protocol/foreign.d.ts +51 -0
  19. package/lib/types/protocol/sources.d.ts +13 -0
  20. package/lib/types/protocol.d.ts +3 -1
  21. package/lib/types/repo/install.d.ts +4 -0
  22. package/lib/types/routes/deps.d.ts +11 -0
  23. package/lib/types/routes/foreign.d.ts +10 -0
  24. package/lib/types/routes/helpers.d.ts +1 -1
  25. package/lib/types/skillfs/audit.d.ts +58 -0
  26. package/lib/types/skillfs/foreign.d.ts +51 -0
  27. package/lib/types/skillfs/hash.d.ts +24 -0
  28. package/lib/types/skillfs.d.ts +2 -0
  29. package/lib/types/store/store.d.ts +6 -0
  30. package/package.json +2 -2
  31. package/src/client/api.ts +14 -0
  32. package/src/client/locales/common.ts +8 -0
  33. package/src/client/locales/market.ts +20 -0
  34. package/src/client/locales/sources.ts +6 -0
  35. package/src/client/panel/ForeignImportDialog.tsx +130 -0
  36. package/src/client/panel/PanelDialogs.tsx +4 -1
  37. package/src/client/panel/SkillHubPanel.tsx +34 -4
  38. package/src/client/panel/SourceStatusBadge.tsx +23 -13
  39. package/src/client/panel/dialogs.tsx +14 -3
  40. package/src/client/panel/panel.module.css +6 -0
  41. package/src/client/panel/useSkillHub.ts +1 -1
  42. package/src/index.ts +2 -2
  43. package/src/protocol/api.ts +2 -0
  44. package/src/protocol/catalog.ts +56 -0
  45. package/src/protocol/foreign.ts +47 -0
  46. package/src/protocol/sources.ts +13 -0
  47. package/src/protocol.test.ts +59 -0
  48. package/src/protocol.ts +3 -0
  49. package/src/repo/install.ts +34 -0
  50. package/src/repo.test.ts +55 -0
  51. package/src/routes/catalog-data.ts +2 -0
  52. package/src/routes/deps.ts +15 -0
  53. package/src/routes/foreign.ts +127 -0
  54. package/src/routes/helpers.ts +1 -0
  55. package/src/routes/repo-import.ts +4 -1
  56. package/src/routes/sources.ts +53 -3
  57. package/src/routes.test.ts +162 -3
  58. package/src/routes.ts +2 -0
  59. package/src/skillfs/audit.test.ts +182 -0
  60. package/src/skillfs/audit.ts +229 -0
  61. package/src/skillfs/foreign.test.ts +121 -0
  62. package/src/skillfs/foreign.ts +198 -0
  63. package/src/skillfs/hash.test.ts +129 -0
  64. package/src/skillfs/hash.ts +112 -0
  65. package/src/skillfs/scan.ts +17 -0
  66. package/src/skillfs.test.ts +37 -0
  67. package/src/skillfs.ts +2 -0
  68. package/src/store/migrate.ts +7 -1
  69. package/src/store/store.ts +36 -3
  70. package/src/store.test.ts +24 -0
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ANNOUNCEMENT_CHAR_BUDGET, computeAnnouncementBudget, type CatalogSkill } from './protocol.ts'
3
+
4
+ function skill(overrides: Partial<CatalogSkill> = {}): CatalogSkill {
5
+ return {
6
+ name: 'demo',
7
+ description: 'does things',
8
+ invocation: { modelInvocable: true, userInvocable: true },
9
+ provider: 'filesystem',
10
+ writable: true,
11
+ source: 'user-dsh',
12
+ ...overrides,
13
+ }
14
+ }
15
+
16
+ describe('computeAnnouncementBudget', () => {
17
+ it('reports zero for an empty catalog', () => {
18
+ expect(computeAnnouncementBudget([])).toEqual({
19
+ skills: 0,
20
+ chars: 0,
21
+ approxTokens: 0,
22
+ budget: ANNOUNCEMENT_CHAR_BUDGET,
23
+ overBudget: false,
24
+ })
25
+ })
26
+
27
+ it('sums name, description and whenToUse', () => {
28
+ const budget = computeAnnouncementBudget([
29
+ skill({ name: 'abc', description: 'de', whenToUse: 'fg' }),
30
+ ])
31
+ expect(budget.skills).toBe(1)
32
+ expect(budget.chars).toBe(7)
33
+ expect(budget.approxTokens).toBe(3)
34
+ })
35
+
36
+ it('skips skills the model cannot invoke', () => {
37
+ const budget = computeAnnouncementBudget([
38
+ skill({ name: 'model-visible', description: '' }),
39
+ skill({ name: 'hidden', description: '', invocation: { modelInvocable: false, userInvocable: true } }),
40
+ ])
41
+ expect(budget.skills).toBe(1)
42
+ expect(budget.chars).toBe('model-visible'.length)
43
+ })
44
+
45
+ it('flags a catalog over the budget', () => {
46
+ const budget = computeAnnouncementBudget([
47
+ skill({ name: 'a', description: 'x'.repeat(ANNOUNCEMENT_CHAR_BUDGET) }),
48
+ ])
49
+ expect(budget.overBudget).toBe(true)
50
+ })
51
+
52
+ it('treats exactly the budget as within it', () => {
53
+ const budget = computeAnnouncementBudget([
54
+ skill({ name: 'a', description: 'x'.repeat(ANNOUNCEMENT_CHAR_BUDGET - 1) }),
55
+ ])
56
+ expect(budget.chars).toBe(ANNOUNCEMENT_CHAR_BUDGET)
57
+ expect(budget.overBudget).toBe(false)
58
+ })
59
+ })
package/src/protocol.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  CatalogSkill,
16
16
  DisabledSkill,
17
17
  DiagnosticEntry,
18
+ AnnouncementBudget,
18
19
  DiagnosticFixRequest,
19
20
  DiagnosticFixResponse,
20
21
  CatalogResponse,
@@ -29,6 +30,7 @@ export type {
29
30
  CreateRequest,
30
31
  CreateResponse,
31
32
  } from './protocol/catalog.ts'
33
+ export { ANNOUNCEMENT_CHAR_BUDGET, computeAnnouncementBudget } from './protocol/catalog.ts'
32
34
  export type { SkillStat, StatsResponse, SkillStatsCheckpoint } from './protocol/stats.ts'
33
35
  export type { ErrorResponse, HubConfig, HubSettingsValue, RedactedHubConfig, ConfigResponse, ConfigRequest } from './protocol/config.ts'
34
36
  export { HUB_CONFIG_DEFAULTS, HUB_ENTRY_ID, HEX_COLOR_RE, GITHUB_TOKEN_RE, resolveHubConfig, redactGithubToken } from './protocol/config.ts'
@@ -89,3 +91,4 @@ export type {
89
91
  SourceTrashClearResponse,
90
92
  } from './protocol/sources.ts'
91
93
  export { isProjectSource } from './protocol/sources.ts'
94
+ export type { ForeignRootView, ForeignSkillView, ForeignListResponse, ForeignImportRequest, ForeignImportResponse } from './protocol/foreign.ts'
@@ -10,6 +10,7 @@ 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 { auditSkillBundle, blockingReason, MAX_SKILL_FILE_BYTES, type AuditBundleFile } from '../skillfs/audit.ts'
13
14
  import { NO_COMPRESSION, RepoFetchError, fetchError, githubAuthHeaders, isAbortError } from './github-client.ts'
14
15
  import { relativeToSkillDir } from './discovery.ts'
15
16
  import type { RepoFile } from './types.ts'
@@ -57,10 +58,25 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
57
58
  }
58
59
  }
59
60
 
61
+ /**
62
+ * Treat a downloaded buffer as text when it plausibly is one: no NUL byte and
63
+ * no U+FFFD from a failed UTF-8 decode. Binary files (icons, archives) are
64
+ * size-checked only — running the text rules over a PNG would produce noise.
65
+ */
66
+ function asAuditText(buffer: Buffer): string | undefined {
67
+ if (buffer.includes(0)) return undefined
68
+ const text = buffer.toString('utf8')
69
+ return text.includes('\uFFFD') ? undefined : text
70
+ }
71
+
60
72
  /**
61
73
  * Download a full skill directory into a temporary dir, validate SKILL.md,
62
74
  * then atomically rename it into place. The target root is created when missing.
63
75
  * Callers pass a target root and a skill entry plus its collected files.
76
+ *
77
+ * 写盘前有两道闸:体积/数量上限(预检用 tree 的 size,落盘前再用实际字节复核)
78
+ * 和内容审计(隐藏字符、危险命令、凭据路径)。审计的 `block` 级发现直接拒绝
79
+ * 导入 —— 一个能藏住指令的技能不该靠用户「看得仔细」来兜底。
64
80
  */
65
81
  export async function downloadRepoSkill(
66
82
  repo: string,
@@ -73,6 +89,12 @@ export async function downloadRepoSkill(
73
89
  onProgress?: (bytes: number, file: string) => void,
74
90
  ): Promise<{ targetDir: string; skillPath: string }> {
75
91
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
92
+ // 预检:tree 的 size 可能缺失(按 0 计),所以这里只拦「确定越界」的,
93
+ // 真正的裁决在下载后用实际字节再做一次。目的是尽早失败,别先下 200MB。
94
+ const preflight = blockingReason(auditSkillBundle(
95
+ files.map((file) => ({ path: relativeToSkillDir(entry.dir, file.path), size: file.size })),
96
+ ))
97
+ if (preflight !== undefined) throw new RepoFetchError(preflight, 422)
76
98
  const targetDir = join(targetRoot, entry.name)
77
99
  await mkdir(targetRoot, { recursive: true })
78
100
  // Dot-prefixed so a leftover temp dir can never surface as a skill in the
@@ -80,12 +102,21 @@ export async function downloadRepoSkill(
80
102
  const tempDir = await mkdtemp(join(targetRoot, '.' + entry.name + '.import-'))
81
103
  let renamed = false
82
104
  try {
105
+ // 并发 worker 直接 push 一个共享数组:JS 单线程,push 是原子的;
106
+ // 顺序不重要,审计只关心内容集合。
107
+ const audited: AuditBundleFile[] = []
83
108
  await mapConcurrent(files, 6, async (file) => {
84
109
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
85
110
  const relative = relativeToSkillDir(entry.dir, file.path)
86
111
  if (relative === '' || relative.includes('..')) throw new RepoFetchError('unsafe repo path: ' + file.path)
87
112
  const target = join(tempDir, relative)
88
113
  const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal)
114
+ // tree 的 size 是声明值,可以撒谎;实际字节才是硬约束。
115
+ if (buffer.length > MAX_SKILL_FILE_BYTES) {
116
+ throw new RepoFetchError(`skill rejected by content audit: ${relative} is ${buffer.length} bytes, over the per-file limit`, 422)
117
+ }
118
+ const text = asAuditText(buffer)
119
+ audited.push({ path: relative, size: buffer.length, ...(text !== undefined ? { text } : {}) })
89
120
  await mkdir(dirname(target), { recursive: true })
90
121
  await writeFile(target, buffer)
91
122
  onProgress?.(buffer.length, relative)
@@ -105,6 +136,9 @@ export async function downloadRepoSkill(
105
136
  throw new RepoFetchError('downloaded skill declares name "' + parsed.value.name + '", expected "' + entry.name + '"', 422)
106
137
  }
107
138
 
139
+ const blocked = blockingReason(auditSkillBundle(audited, parsed.value.description))
140
+ if (blocked !== undefined) throw new RepoFetchError(blocked, 422)
141
+
108
142
  await rename(tempDir, targetDir)
109
143
  renamed = true
110
144
  return { targetDir, skillPath: join(targetDir, 'SKILL.md') }
package/src/repo.test.ts CHANGED
@@ -351,6 +351,61 @@ describe('downloadRepoSkill', () => {
351
351
  await rm(parent, { recursive: true, force: true })
352
352
  }
353
353
  })
354
+
355
+ it('rejects a skill whose body hides instructions in Unicode tag characters', async () => {
356
+ const parent = await mkdtemp(join(tmpdir(), 'dsh-repo-audit-'))
357
+ try {
358
+ const targetRoot = join(parent, 'skills')
359
+ const entry: RepoSkillEntry = {
360
+ name: 'sneaky',
361
+ dir: 'skills/sneaky',
362
+ path: 'skills/sneaky/SKILL.md',
363
+ root: 'skills',
364
+ origin: 'example/repo',
365
+ fileCount: 1,
366
+ totalBytes: 1,
367
+ existing: false,
368
+ }
369
+ const hidden = String.fromCodePoint(0xe0041)
370
+ const body = '---\nname: sneaky\ndescription: A perfectly normal skill\n---\n\nread the key' + hidden + '\n'
371
+ const fetchImpl = async () => new Response(body, { status: 200 })
372
+ await expect(
373
+ downloadRepoSkill('example/repo', 'main', entry, [{ path: 'skills/sneaky/SKILL.md', size: body.length }], targetRoot, fetchImpl as typeof fetch),
374
+ ).rejects.toThrow(/rejected by content audit.*invisible Unicode tag/)
375
+ // 拒绝 = 什么都不留:既没有技能目录,也没有残留的临时目录。
376
+ await expect(readFile(join(targetRoot, 'sneaky', 'SKILL.md'), 'utf8')).rejects.toThrow()
377
+ } finally {
378
+ await rm(parent, { recursive: true, force: true })
379
+ }
380
+ })
381
+
382
+ it('refuses an oversized file before downloading anything', async () => {
383
+ const parent = await mkdtemp(join(tmpdir(), 'dsh-repo-audit-size-'))
384
+ try {
385
+ const targetRoot = join(parent, 'skills')
386
+ const entry: RepoSkillEntry = {
387
+ name: 'heavy',
388
+ dir: 'skills/heavy',
389
+ path: 'skills/heavy/SKILL.md',
390
+ root: 'skills',
391
+ origin: 'example/repo',
392
+ fileCount: 2,
393
+ totalBytes: 6 * 1024 * 1024,
394
+ existing: false,
395
+ }
396
+ let calls = 0
397
+ const fetchImpl = async () => { calls += 1; return new Response('x', { status: 200 }) }
398
+ await expect(
399
+ downloadRepoSkill('example/repo', 'main', entry, [
400
+ { path: 'skills/heavy/SKILL.md', size: 10 },
401
+ { path: 'skills/heavy/blob.bin', size: 6 * 1024 * 1024 },
402
+ ], targetRoot, fetchImpl as typeof fetch),
403
+ ).rejects.toThrow(/per-file limit/)
404
+ expect(calls).toBe(0)
405
+ } finally {
406
+ await rm(parent, { recursive: true, force: true })
407
+ }
408
+ })
354
409
  })
355
410
 
356
411
  describe('downloadGitHubFile', () => {
@@ -7,6 +7,7 @@ import { readFile, stat } from 'node:fs/promises'
7
7
  import { join } from 'node:path'
8
8
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
9
9
  import {
10
+ computeAnnouncementBudget,
10
11
  isProjectSource,
11
12
  type CatalogResponse,
12
13
  type CatalogSkill,
@@ -213,6 +214,7 @@ export async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promi
213
214
  skills,
214
215
  disabled,
215
216
  diagnostics,
217
+ announcement: computeAnnouncementBudget(skills),
216
218
  ...(duplicateNames.length > 0 ? { duplicateNames } : {}),
217
219
  }
218
220
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ServerResponse } from 'node:http'
7
+ import { homedir } from 'node:os'
7
8
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
8
9
  import { resolveHubConfig, type HubConfig, type WritableRoot } from '../protocol.ts'
9
10
  import { rootOfPath } from '../skillfs.ts'
@@ -35,6 +36,12 @@ export interface SkillHubRouteDeps {
35
36
  fence?: RouteFence
36
37
  /** DSH home override (tests isolate the writable roots; defaults to ~/.dsh). */
37
38
  home?: string
39
+ /**
40
+ * 用户主目录覆盖(其他 agent 的 `~/.claude/skills` 等相对它解析)。
41
+ * 与 `home` 是两个概念:`home` 是 DSH 自己的状态根(`~/.dsh`),
42
+ * 这个才是 `/Users/<user>`。测试用它把外部扫描圈在临时目录里。
43
+ */
44
+ userHome?: string
38
45
  /** Invalidate the registry catalog cache after hub-driven mutations. */
39
46
  invalidate?: () => void
40
47
  /** Optional invocation-count reader; absent means the stats route reports unavailable. */
@@ -74,6 +81,14 @@ export function homeOf(deps: SkillHubRouteDeps): string {
74
81
  return deps.home ?? dshHome()
75
82
  }
76
83
 
84
+ /**
85
+ * Resolve the user's home directory — the base every other agent's user-level
86
+ * skill folder hangs off. Distinct from `homeOf` (the DSH state root).
87
+ */
88
+ export function userHomeOf(deps: SkillHubRouteDeps): string {
89
+ return deps.userHome ?? homedir()
90
+ }
91
+
77
92
  /** A skill resolved as a hub-writable user-level file. */
78
93
  export interface WritableSkill {
79
94
  skill: SkillDefinition
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 外部 agent 技能域路由:发现(只读扫描别家目录)与收编(复制进本地可写根)。
3
+ *
4
+ * 两条约束:
5
+ * 1. 外部目录只读 —— 扫描不会 mkdir,也不会写任何东西到别家路径下;
6
+ * 2. 导入不信任前端给的路径:只接受 `agent + name`,服务端重新扫一遍定位,
7
+ * 否则一个拼出来的绝对路径就能把任意文件复制进技能目录。
8
+ */
9
+
10
+ import { mkdir, stat } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import {
13
+ SKILL_HUB_API,
14
+ type ForeignImportResponse,
15
+ type ForeignListResponse,
16
+ type ForeignSkillView,
17
+ } from '../protocol.ts'
18
+ import { foreignRoots, importForeignSkill, occupancyOf, rootPath, scanForeignSkills } from '../skillfs.ts'
19
+ import { errorText } from '../error-text.ts'
20
+ import { homeOf, knownSkillNames, pathExists, userHomeOf, writeError, writeJson, type RouteSpec, type SkillHubRouteDeps } from './helpers.ts'
21
+ import { retireNamePlaceholders } from './route-state.ts'
22
+
23
+ /** 判断一个外部根是否真实存在(只 stat 一次,不列目录)。 */
24
+ async function dirExists(path: string): Promise<boolean> {
25
+ try {
26
+ return (await stat(path)).isDirectory()
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ export function foreignRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
33
+ return [
34
+ // ------------------------------------------------------------- foreign
35
+ // 列出其他 agent 用户级技能目录里的可收编技能。
36
+ {
37
+ path: SKILL_HUB_API.foreign,
38
+ methods: ['GET'],
39
+ handler: async ({ res }) => {
40
+ const home = homeOf(deps)
41
+ // 外部技能目录挂在**用户主目录**下(`~/.claude/skills`),不是 DSH 的
42
+ // 状态根(`~/.dsh`)。两者混用会扫到一个永远不存在的位置。
43
+ const userHome = userHomeOf(deps)
44
+ const existing = await knownSkillNames(deps, { includeDisabled: false })
45
+ const skills = await scanForeignSkills(userHome, existing)
46
+ const roots = await Promise.all(foreignRoots(userHome).map(async (root) => ({ ...root, present: await dirExists(root.dir) })))
47
+ const targetRoot = rootPath('user-dsh', home)
48
+ // 注册表快照是权威的(含项目级/内置),但它可能比磁盘慢一拍:刚写进去
49
+ // 还没被 watcher 收进索引时,快照会说「没有」。只对快照没命中的做一次
50
+ // 磁盘确认,避免把冲突项标成可导入。
51
+ //
52
+ // 判据只看**启用态**的发现文件:`<name>/SKILL.md` 或扁平 `<name>.md`。
53
+ // 目录存在但只有 `SKILL.md.disabled`(或干脆是空壳)不算冲突 —— 那正是
54
+ // 导入时会被移入回收站让位的占位,标成「已存在」会让用户根本选不了。
55
+ const view: ForeignSkillView[] = await Promise.all(skills.map(async (skill) => ({
56
+ name: skill.name,
57
+ description: skill.description,
58
+ agent: skill.agent,
59
+ path: skill.path,
60
+ kind: skill.kind,
61
+ existing: skill.existing
62
+ || await pathExists(join(targetRoot, skill.name, 'SKILL.md'))
63
+ || await pathExists(join(targetRoot, skill.name + '.md')),
64
+ })))
65
+ writeJson(res, 200, { ok: true, roots: roots.filter((root) => root.present), skills: view } satisfies ForeignListResponse)
66
+ },
67
+ },
68
+ // ------------------------------------------------------ foreign/import
69
+ // 按 agent + name 收编。逐条独立成败,一条失败不影响其余。
70
+ {
71
+ path: SKILL_HUB_API.foreignImport,
72
+ methods: ['POST'],
73
+ jsonBody: true,
74
+ handler: async ({ res, body }) => {
75
+ const items = Array.isArray(body.items) ? body.items : []
76
+ if (items.length === 0) { writeError(res, 400, 'items must be a non-empty array'); return }
77
+ if (items.length > 100) { writeError(res, 400, 'import at most 100 skills per request'); return }
78
+ const home = homeOf(deps)
79
+ const userHome = userHomeOf(deps)
80
+ const existing = await knownSkillNames(deps, { includeDisabled: false })
81
+ // 一次扫描覆盖所有条目:逐个条目扫一遍目录会让 20 个技能 = 20 次全盘 readdir。
82
+ const available = await scanForeignSkills(userHome, existing)
83
+ const targetRoot = rootPath('user-dsh', home)
84
+ await mkdir(targetRoot, { recursive: true })
85
+ const imported: string[] = []
86
+ const replaced: string[] = []
87
+ const skipped: Array<{ name: string; reason: string }> = []
88
+ const failed: Array<{ name: string; error: string }> = []
89
+ const seen = new Set<string>()
90
+ for (const raw of items) {
91
+ const item = raw as { agent?: unknown; name?: unknown } | null
92
+ const agent = item !== null && typeof item.agent === 'string' ? item.agent : ''
93
+ const name = item !== null && typeof item.name === 'string' ? item.name : ''
94
+ if (agent === '' || name === '') { failed.push({ name, error: 'agent and name are required' }); continue }
95
+ if (seen.has(agent + '/' + name)) continue
96
+ seen.add(agent + '/' + name)
97
+ // 只认服务端自己扫出来的条目:前端给的名字必须对得上一条真实记录。
98
+ const skill = available.find((candidate) => candidate.agent === agent && candidate.name === name)
99
+ if (skill === undefined) { failed.push({ name, error: 'skill not found under ' + agent }); continue }
100
+ if (skill.existing) {
101
+ skipped.push({ name, reason: 'a skill with this name already exists' })
102
+ continue
103
+ }
104
+ // 磁盘上可能有已禁用的同名占位或空壳目录:那不算「已存在」,但目标目录
105
+ // 非空,裸 rename 会 ENOTEMPTY。先让它进回收站(可恢复),与市场导入同一语义。
106
+ const occupancy = await occupancyOf(targetRoot, name)
107
+ if (occupancy.enabled.length > 0) {
108
+ skipped.push({ name, reason: 'a skill with this name already exists' })
109
+ continue
110
+ }
111
+ if (occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
112
+ const trashed = await retireNamePlaceholders(deps, targetRoot, name)
113
+ if (trashed !== undefined) replaced.push(name)
114
+ }
115
+ try {
116
+ await importForeignSkill(skill, targetRoot)
117
+ imported.push(name)
118
+ } catch (error) {
119
+ failed.push({ name, error: errorText(error) })
120
+ }
121
+ }
122
+ if (imported.length > 0) deps.invalidate?.()
123
+ writeJson(res, 200, { ok: true, imported, ...(replaced.length > 0 ? { replaced } : {}), skipped, failed } satisfies ForeignImportResponse)
124
+ },
125
+ },
126
+ ]
127
+ }
@@ -33,6 +33,7 @@ export {
33
33
  isWritableSource,
34
34
  resolveWritableSkill,
35
35
  savedOf,
36
+ userHomeOf,
36
37
  } from './deps.ts'
37
38
  export type {
38
39
  SkillHubRouteDeps,
@@ -27,7 +27,7 @@ import {
27
27
  skillManifest,
28
28
  skillPathIn,
29
29
  } from '../repo.ts'
30
- import { occupancyOf, rootPath } from '../skillfs.ts'
30
+ import { hashSkillAt, occupancyOf, rootPath } from '../skillfs.ts'
31
31
  import { errorText } from '../error-text.ts'
32
32
  import {
33
33
  homeOf,
@@ -211,6 +211,9 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
211
211
  })
212
212
  await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
213
213
  await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
214
+ // 采集刚落盘内容的哈希:这是之后区分「上游变了」与「本地改过」的唯一依据。
215
+ const hash = await hashSkillAt(result.skillPath)
216
+ if (hash !== undefined) await deps.store.setSourceSkillHash(repo, entry.name, hash)
214
217
  if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, entry.name)
215
218
  job.imported.push({ name: entry.name, origin: entry.origin, path: result.skillPath })
216
219
  needInvalidate = true
@@ -30,8 +30,9 @@ import {
30
30
  skillFileAt,
31
31
  skillManifest,
32
32
  } from '../repo.ts'
33
- import { clearTrash, occupancyOf, restoreSkill, rootOfPath, rootPath, trashSkill, trashSourceOf, WRITABLE_ROOTS } from '../skillfs.ts'
33
+ import { clearTrash, hashSkillAt, occupancyOf, restoreSkill, rootOfPath, rootPath, trashSkill, trashSourceOf, WRITABLE_ROOTS } from '../skillfs.ts'
34
34
  import { errorText } from '../error-text.ts'
35
+ import { mapConcurrent } from '../concurrency.ts'
35
36
  import {
36
37
  buildCollections,
37
38
  homeOf,
@@ -45,6 +46,32 @@ import {
45
46
  } from './helpers.ts'
46
47
  import { MIN_CHECK_INTERVAL_MS, lastSourceCheck, replaceInPlace, retireNamePlaceholders } from './route-state.ts'
47
48
 
49
+ /**
50
+ * 本地被改过的技能:磁盘内容哈希 ≠ 导入/同步时记录的哈希。
51
+ *
52
+ * 只对「记录里已有哈希」的技能判定 —— 旧记录没采集过哈希,硬比会得到
53
+ * 「全部已改」的假警报。技能目录读不到(被删/被移走)同样不算改过:
54
+ * 那是另一种状态,由发现诊断去报。
55
+ */
56
+ async function localModifiedSkills(source: { skills: readonly string[]; localHashes?: Record<string, string> }, home: string): Promise<string[]> {
57
+ const hashes = source.localHashes
58
+ if (hashes === undefined || Object.keys(hashes).length === 0) return []
59
+ const base = rootPath('user-dsh', home)
60
+ const modified: string[] = []
61
+ // 有界并发,与 scanDiagnostics 同一理由:一个来源可能跟踪几十个技能,
62
+ // 裸 Promise.all 会同时打开上百个文件句柄。
63
+ await mapConcurrent(source.skills, 8, async (name) => {
64
+ const expected = hashes[name]
65
+ if (expected === undefined) return
66
+ const occupancy = await occupancyOf(base, name)
67
+ const path = occupancy.enabled[0] ?? occupancy.disabled[0]
68
+ if (path === undefined) return
69
+ const actual = await hashSkillAt(path)
70
+ if (actual !== undefined && actual !== expected) modified.push(name)
71
+ })
72
+ return modified.sort((a, b) => a.localeCompare(b))
73
+ }
74
+
48
75
  /** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
49
76
  export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
50
77
  return [
@@ -78,9 +105,16 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
78
105
  if (only !== undefined && source === undefined) { writeError(res, 404, 'source not found: ' + only); return }
79
106
  const sources = source !== undefined ? [source] : await deps.store.listSources()
80
107
  if (lastSourceCheck.size > 500) lastSourceCheck.clear()
108
+ const home = homeOf(deps)
81
109
  const results: SourceCheckResult[] = []
82
110
  for (const item of sources) {
83
- const base = { repo: item.repo, ...(item.ref !== undefined ? { ref: item.ref } : {}) }
111
+ // 本地改动是磁盘事实,与网络检查成败无关,所以放在节流判断之前。
112
+ const modified = await localModifiedSkills(item, home)
113
+ const base = {
114
+ repo: item.repo,
115
+ ...(item.ref !== undefined ? { ref: item.ref } : {}),
116
+ ...(modified.length > 0 ? { localModified: modified } : {}),
117
+ }
84
118
  const now = Date.now()
85
119
  const last = lastSourceCheck.get(item.repo) ?? 0
86
120
  if (now - last < MIN_CHECK_INTERVAL_MS) {
@@ -131,6 +165,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
131
165
  const targetRoot = rootPath('user-dsh', homeOf(deps))
132
166
  await mkdir(targetRoot, { recursive: true })
133
167
  const synced: string[] = []
168
+ const overwritten: string[] = []
134
169
  const failed: Array<{ name: string; error: string }> = []
135
170
  for (const name of targets) {
136
171
  // Sync writes under the user root: only accept real skill names
@@ -154,6 +189,14 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
154
189
  // 落盘位置永远是目录包,所以本地无论是目录包还是扁平文件(启用或禁用)
155
190
  // 都得先让开,否则同名两形态会并存(issue #16 类)。
156
191
  const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
192
+ // 覆盖前的如实上报:本地内容与记录不符时,这次同步会丢掉用户的改动。
193
+ // 是否继续由调用方(GUI 确认弹窗)决定,这里只负责不隐瞒。
194
+ const expectedHash = source.localHashes?.[name]
195
+ const localPath = occupancy.enabled[0] ?? occupancy.disabled[0]
196
+ if (expectedHash !== undefined && localPath !== undefined) {
197
+ const actualHash = await hashSkillAt(localPath)
198
+ if (actualHash !== undefined && actualHash !== expectedHash) overwritten.push(name)
199
+ }
157
200
  // 1) 禁用件与空壳让位(顺带清掉 sidecar 禁用记录)。
158
201
  await retireNamePlaceholders(deps, targetRoot, name)
159
202
  // 2) 启用件按「替换」语义处理:先挪到点前缀备份,下载失败则原样恢复。
@@ -175,6 +218,13 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
175
218
  await deps.store.removeDisabled(name)
176
219
  }
177
220
  await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
221
+ // 落盘后的实际内容才是新基线:禁用态文件已改名,所以要重新解析占位。
222
+ const settled = await occupancyOf(targetRoot, name)
223
+ const settledPath = settled.enabled[0] ?? settled.disabled[0]
224
+ if (settledPath !== undefined) {
225
+ const hash = await hashSkillAt(settledPath)
226
+ if (hash !== undefined) await deps.store.setSourceSkillHash(repo, name, hash)
227
+ }
178
228
  synced.push(name)
179
229
  } catch (error) {
180
230
  failed.push({ name, error: errorText(error) })
@@ -187,7 +237,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
187
237
  await deps.store.setSourceCommit(repo, latest.commitSha)
188
238
  }
189
239
  deps.invalidate?.()
190
- writeJson(res, 200, { ok: true, repo, commitSha: latest.commitSha, synced, failed } satisfies SourceSyncResponse)
240
+ writeJson(res, 200, { ok: true, repo, commitSha: latest.commitSha, synced, ...(overwritten.length > 0 ? { overwritten } : {}), failed } satisfies SourceSyncResponse)
191
241
  },
192
242
  },
193
243
  // ------------------------------------------------------- sources/delete