dsh-skill-hub 0.3.26 → 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 +11 -3
- package/README.zh.md +11 -3
- 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 +23 -21
- 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
package/src/protocol/catalog.ts
CHANGED
|
@@ -54,6 +54,60 @@ export interface DiagnosticEntry {
|
|
|
54
54
|
reason: string
|
|
55
55
|
/** Whether this diagnostic can be auto-fixed (e.g. unquoted colon). */
|
|
56
56
|
fixable?: boolean
|
|
57
|
+
/**
|
|
58
|
+
* 条目来源。缺省 = 发现诊断(provider 跳过了这个文件);`security` = 内容审计
|
|
59
|
+
* (文件能被加载,但正文里有值得看一眼的东西)。两者共用同一块 UI,但
|
|
60
|
+
* security 条目不能「修复」,只能说清问题在哪一行。
|
|
61
|
+
*/
|
|
62
|
+
kind?: 'security'
|
|
63
|
+
/** security 条目的严重度;发现诊断条目省略。 */
|
|
64
|
+
severity?: 'block' | 'warn'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 技能目录在 system prompt 里占用的公告预算。渐进加载的第一层只放
|
|
69
|
+
* name + description,但技能多了这一层本身就会顶到客户端的上限而被截断,
|
|
70
|
+
* 表现为「装了却不触发」—— 面板必须能看见这个数字。
|
|
71
|
+
*/
|
|
72
|
+
export interface AnnouncementBudget {
|
|
73
|
+
/** 参与公告的技能数(已启用且 modelInvocable)。 */
|
|
74
|
+
skills: number
|
|
75
|
+
/** name + description + whenToUse 的总字符数。 */
|
|
76
|
+
chars: number
|
|
77
|
+
/** 粗略 token 估算(按 3 字符/token)。 */
|
|
78
|
+
approxTokens: number
|
|
79
|
+
/** 建议预算(字符),超出即 overBudget。 */
|
|
80
|
+
budget: number
|
|
81
|
+
overBudget: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 技能目录的建议字符预算。取 8000:这是 Codex 在上下文窗口未知时对初始
|
|
86
|
+
* 技能清单的硬上限,也是目前公开客户端里最保守的一档。超出后尾部条目会被
|
|
87
|
+
* 静默丢弃(技能看起来装了,实际不触发)。
|
|
88
|
+
*/
|
|
89
|
+
export const ANNOUNCEMENT_CHAR_BUDGET = 8000
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 按「已启用且模型可调用」的技能估算公告成本。只统计一层渐进加载真正注入的
|
|
93
|
+
* 字段(name + description + whenToUse),不含正文 —— 正文是激活后才载入的,
|
|
94
|
+
* 不占常驻预算。
|
|
95
|
+
*/
|
|
96
|
+
export function computeAnnouncementBudget(skills: readonly CatalogSkill[]): AnnouncementBudget {
|
|
97
|
+
let chars = 0
|
|
98
|
+
let count = 0
|
|
99
|
+
for (const skill of skills) {
|
|
100
|
+
if (!skill.invocation.modelInvocable) continue
|
|
101
|
+
count += 1
|
|
102
|
+
chars += skill.name.length + skill.description.length + (skill.whenToUse?.length ?? 0)
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
skills: count,
|
|
106
|
+
chars,
|
|
107
|
+
approxTokens: Math.ceil(chars / 3),
|
|
108
|
+
budget: ANNOUNCEMENT_CHAR_BUDGET,
|
|
109
|
+
overBudget: chars > ANNOUNCEMENT_CHAR_BUDGET,
|
|
110
|
+
}
|
|
57
111
|
}
|
|
58
112
|
|
|
59
113
|
/** POST /api/skill-hub/diagnostic/fix — repair a fixable diagnostic in place. */
|
|
@@ -78,6 +132,8 @@ export interface CatalogResponse {
|
|
|
78
132
|
disabled: DisabledSkill[]
|
|
79
133
|
/** Files in the writable roots the provider ignores, with reasons. */
|
|
80
134
|
diagnostics: DiagnosticEntry[]
|
|
135
|
+
/** 技能目录占用的公告预算(见 AnnouncementBudget)。 */
|
|
136
|
+
announcement: AnnouncementBudget
|
|
81
137
|
/** Skill names that appeared in multiple roots (first wins, others hidden). Mirrors codex name_counts. */
|
|
82
138
|
duplicateNames?: string[]
|
|
83
139
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部 agent 技能(收编)的线格式。
|
|
3
|
+
*
|
|
4
|
+
* 发现源是别的 agent 的用户级技能目录(Claude Code / Codex / Cursor / …),
|
|
5
|
+
* 它们一律只读:这里只描述「看到了什么」和「要收编哪一个」。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 一个被扫描的外部技能根。 */
|
|
9
|
+
export interface ForeignRootView {
|
|
10
|
+
agent: string
|
|
11
|
+
dir: string
|
|
12
|
+
/** 该目录在本机是否存在(不存在时 skills 里不会出现它的条目)。 */
|
|
13
|
+
present: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 一条可收编的外部技能。 */
|
|
17
|
+
export interface ForeignSkillView {
|
|
18
|
+
name: string
|
|
19
|
+
description: string
|
|
20
|
+
agent: string
|
|
21
|
+
/** 发现文件绝对路径(只读展示)。 */
|
|
22
|
+
path: string
|
|
23
|
+
kind: 'directory' | 'flat'
|
|
24
|
+
/** 目标根已有同名技能,不可导入。 */
|
|
25
|
+
existing: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** GET /api/skill-hub/foreign */
|
|
29
|
+
export interface ForeignListResponse {
|
|
30
|
+
ok: true
|
|
31
|
+
roots: ForeignRootView[]
|
|
32
|
+
skills: ForeignSkillView[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** POST /api/skill-hub/foreign/import — 按 agent + 名字收编(不信任前端给的路径)。 */
|
|
36
|
+
export interface ForeignImportRequest {
|
|
37
|
+
items: Array<{ agent: string; name: string }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ForeignImportResponse {
|
|
41
|
+
ok: true
|
|
42
|
+
imported: string[]
|
|
43
|
+
/** 导入前被移入回收站的同名占位(已禁用的发现文件 / 空壳目录),可恢复。 */
|
|
44
|
+
replaced?: string[]
|
|
45
|
+
skipped: Array<{ name: string; reason: string }>
|
|
46
|
+
failed: Array<{ name: string; error: string }>
|
|
47
|
+
}
|
package/src/protocol/sources.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface SourceRecord {
|
|
|
15
15
|
skills: string[]
|
|
16
16
|
/** 仓库内路径 → 文件大小 的基线清单(差异检测用;缺失 = 无基线)。 */
|
|
17
17
|
manifest?: Record<string, number>
|
|
18
|
+
/**
|
|
19
|
+
* 技能名 → 本地内容哈希(导入/同步落盘后立刻采集)。用于区分「上游变了」
|
|
20
|
+
* 与「本地被改过」:没有它,「全部更新」只能盲覆盖。缺失 = 该技能还没采集过
|
|
21
|
+
* (旧记录),比对时按「未知」处理,不误报为已修改。
|
|
22
|
+
*/
|
|
23
|
+
localHashes?: Record<string, string>
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/** 回收站条目:上游删除后跟进删除(移入 .trash)的技能。 */
|
|
@@ -77,6 +83,11 @@ export interface SourceCheckResult {
|
|
|
77
83
|
throttled?: boolean
|
|
78
84
|
/** 该来源此前没有快照(迁移/旧记录),本次已回填 commit,尚未有差异基线。 */
|
|
79
85
|
unverified?: boolean
|
|
86
|
+
/**
|
|
87
|
+
* 本地磁盘内容与上次导入/同步时不一致的技能(用户自己改过)。只在检查
|
|
88
|
+
* 时本地能读到文件才判定;哈希未采集过(旧记录)的技能不出现在这里。
|
|
89
|
+
*/
|
|
90
|
+
localModified?: string[]
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
/** POST /api/skill-hub/sources/check */
|
|
@@ -100,6 +111,8 @@ export interface SourceSyncResponse {
|
|
|
100
111
|
commitSha: string
|
|
101
112
|
/** 成功同步的技能。 */
|
|
102
113
|
synced: string[]
|
|
114
|
+
/** 本次同步覆盖掉的、本地有改动的技能(改前哈希 ≠ 记录哈希)。 */
|
|
115
|
+
overwritten?: string[]
|
|
103
116
|
/** 失败的技能。 */
|
|
104
117
|
failed: Array<{ name: string; error: string }>
|
|
105
118
|
}
|
|
@@ -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
|