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.
- package/CONTRIBUTING.md +1 -1
- package/README.md +10 -2
- package/README.zh.md +10 -2
- package/lib/client.js +310 -39
- package/lib/client.js.map +1 -1
- package/lib/index.js +790 -15
- package/lib/types/client/api.d.ts +8 -1
- package/lib/types/client/locales/common.d.ts +4 -0
- package/lib/types/client/locales/market.d.ts +10 -0
- package/lib/types/client/locales/sources.d.ts +3 -0
- package/lib/types/client/locales.d.ts +17 -0
- package/lib/types/client/panel/ForeignImportDialog.d.ts +15 -0
- package/lib/types/client/panel/PanelDialogs.d.ts +2 -0
- package/lib/types/client/panel/dialogs.d.ts +11 -1
- package/lib/types/client/panel/useSkillHub.d.ts +1 -0
- package/lib/types/protocol/api.d.ts +2 -0
- package/lib/types/protocol/catalog.d.ts +39 -1
- package/lib/types/protocol/foreign.d.ts +51 -0
- package/lib/types/protocol/sources.d.ts +13 -0
- package/lib/types/protocol.d.ts +3 -1
- package/lib/types/repo/install.d.ts +4 -0
- package/lib/types/routes/deps.d.ts +11 -0
- package/lib/types/routes/foreign.d.ts +10 -0
- package/lib/types/routes/helpers.d.ts +1 -1
- package/lib/types/skillfs/audit.d.ts +58 -0
- package/lib/types/skillfs/foreign.d.ts +51 -0
- package/lib/types/skillfs/hash.d.ts +24 -0
- package/lib/types/skillfs.d.ts +2 -0
- package/lib/types/store/store.d.ts +6 -0
- package/package.json +2 -2
- package/src/client/api.ts +14 -0
- package/src/client/locales/common.ts +8 -0
- package/src/client/locales/market.ts +20 -0
- package/src/client/locales/sources.ts +6 -0
- package/src/client/panel/ForeignImportDialog.tsx +130 -0
- package/src/client/panel/PanelDialogs.tsx +4 -1
- package/src/client/panel/SkillHubPanel.tsx +34 -4
- package/src/client/panel/SourceStatusBadge.tsx +23 -13
- package/src/client/panel/dialogs.tsx +14 -3
- package/src/client/panel/panel.module.css +6 -0
- package/src/client/panel/useSkillHub.ts +1 -1
- package/src/index.ts +2 -2
- package/src/protocol/api.ts +2 -0
- package/src/protocol/catalog.ts +56 -0
- package/src/protocol/foreign.ts +47 -0
- package/src/protocol/sources.ts +13 -0
- package/src/protocol.test.ts +59 -0
- package/src/protocol.ts +3 -0
- package/src/repo/install.ts +34 -0
- package/src/repo.test.ts +55 -0
- package/src/routes/catalog-data.ts +2 -0
- package/src/routes/deps.ts +15 -0
- package/src/routes/foreign.ts +127 -0
- package/src/routes/helpers.ts +1 -0
- package/src/routes/repo-import.ts +4 -1
- package/src/routes/sources.ts +53 -3
- package/src/routes.test.ts +162 -3
- package/src/routes.ts +2 -0
- package/src/skillfs/audit.test.ts +182 -0
- package/src/skillfs/audit.ts +229 -0
- package/src/skillfs/foreign.test.ts +121 -0
- package/src/skillfs/foreign.ts +198 -0
- package/src/skillfs/hash.test.ts +129 -0
- package/src/skillfs/hash.ts +112 -0
- package/src/skillfs/scan.ts +17 -0
- package/src/skillfs.test.ts +37 -0
- package/src/skillfs.ts +2 -0
- package/src/store/migrate.ts +7 -1
- package/src/store/store.ts +36 -3
- 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'
|
package/src/repo/install.ts
CHANGED
|
@@ -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
|
}
|
package/src/routes/deps.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/routes/helpers.ts
CHANGED
|
@@ -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
|
package/src/routes/sources.ts
CHANGED
|
@@ -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
|
-
|
|
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
|