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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能内容审计:安装前拦截与常驻体检共用的一套纯函数规则。
|
|
3
|
+
*
|
|
4
|
+
* 两条设计约束:
|
|
5
|
+
* 1. 零依赖、纯字符串处理。宿主每 5 秒轮询一次 /catalog,扫描必须廉价,
|
|
6
|
+
* 否则面板响应时间会随技能数量线性变慢(与 scanDiagnostics 同一理由)。
|
|
7
|
+
* 2. 规则只认结构特征(不可见字符、命令管道、凭据路径),不做语义判断。
|
|
8
|
+
* 语义/LLM 判断误报率高且要模型调用,不属于一个管理器的职责边界;
|
|
9
|
+
* 这里的目标是「让藏起来的东西显形」,不是「判定作者意图」。
|
|
10
|
+
*
|
|
11
|
+
* 两级严重度:
|
|
12
|
+
* - `block`:安装阶段拒绝落盘。只用于「没有任何正当用途」的特征 ——
|
|
13
|
+
* 裸 ESC 控制字符、Unicode 标签字符、双向控制符、以及体积越界。
|
|
14
|
+
* - `warn`:落盘但标记。零宽字符、危险命令、凭据路径、超长描述都可能
|
|
15
|
+
* 出现在正常技能里(比如一个教人用 curl 的技能),交给人判断。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 规则 id:稳定,测试与文案都按它断言。 */
|
|
19
|
+
export type AuditRuleId =
|
|
20
|
+
| 'ansi-escape'
|
|
21
|
+
| 'unicode-tag'
|
|
22
|
+
| 'bidi-control'
|
|
23
|
+
| 'zero-width'
|
|
24
|
+
| 'dangerous-command'
|
|
25
|
+
| 'credential-path'
|
|
26
|
+
| 'file-too-large'
|
|
27
|
+
| 'bundle-too-large'
|
|
28
|
+
| 'too-many-files'
|
|
29
|
+
| 'description-too-long'
|
|
30
|
+
|
|
31
|
+
export type AuditSeverity = 'block' | 'warn'
|
|
32
|
+
|
|
33
|
+
/** 一条审计发现。`file` 相对技能目录,便于在面板上直接定位。 */
|
|
34
|
+
export interface AuditFinding {
|
|
35
|
+
rule: AuditRuleId
|
|
36
|
+
severity: AuditSeverity
|
|
37
|
+
file: string
|
|
38
|
+
/** 1 起行号;整文件级规则(体积、文件数)省略。 */
|
|
39
|
+
line?: number
|
|
40
|
+
message: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 单文件字节上限:技能是纯文本加少量脚本,2 MiB 已远超正常体积。 */
|
|
44
|
+
export const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
|
|
45
|
+
/** 单个技能总字节上限。 */
|
|
46
|
+
export const MAX_SKILL_BUNDLE_BYTES = 10 * 1024 * 1024
|
|
47
|
+
/** 单个技能文件数上限。 */
|
|
48
|
+
export const MAX_SKILL_FILES = 500
|
|
49
|
+
/** agentskills.io 规范对 description 的硬上限。 */
|
|
50
|
+
export const MAX_DESCRIPTION_CHARS = 1024
|
|
51
|
+
|
|
52
|
+
/** 裸 ESC —— 终端转义注入(CWE-150)的起点,正常技能正文不含。 */
|
|
53
|
+
const ANSI_RE = /\u001B/
|
|
54
|
+
/** Unicode 标签字符区,唯一用途是承载不可见文本。 */
|
|
55
|
+
const UNICODE_TAG_RE = /[\u{E0000}-\u{E007F}]/u
|
|
56
|
+
/** 双向控制符:Trojan Source 式视觉重排。 */
|
|
57
|
+
const BIDI_RE = /[\u202A-\u202E\u2066-\u2069]/
|
|
58
|
+
/** 零宽字符:可能来自从网页复制的正文,也可能用于隐藏内容。 */
|
|
59
|
+
const ZERO_WIDTH_RE = /[\u200B-\u200D\u2060\uFEFF]/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 危险命令:只匹配「下载后直接执行」这一族,也就是审计报告里出现频率最高、
|
|
63
|
+
* 且人类肉眼最容易滑过去的那几种形态。普通的 `curl URL` 取数据不在此列。
|
|
64
|
+
*/
|
|
65
|
+
const DANGEROUS_COMMANDS: ReadonlyArray<{ re: RegExp; label: string }> = [
|
|
66
|
+
{ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/, label: 'remote script piped straight into a shell' },
|
|
67
|
+
{ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?python[0-9.]*\b/, label: 'remote script piped straight into python' },
|
|
68
|
+
{ re: /\bbase64\s+(?:-d|--decode)\b[^\n|]*\|\s*(?:ba|z|k)?sh\b/, label: 'base64 payload decoded into a shell' },
|
|
69
|
+
{ re: /\beval\s+["']?\$\(\s*(?:curl|wget)\b/, label: 'eval of a downloaded command' },
|
|
70
|
+
{ re: /\bnc\b[^\n]*\s-e\s/, label: 'netcat with command execution' },
|
|
71
|
+
{ re: /\/dev\/tcp\//, label: 'raw TCP socket via /dev/tcp' },
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/** 凭据路径:技能正文提到这些路径本身未必有恶意,但值得让人看一眼上下文。 */
|
|
75
|
+
const CREDENTIAL_PATHS: ReadonlyArray<{ re: RegExp; label: string }> = [
|
|
76
|
+
{ re: /(?:^|[\s"'`/])\.ssh\/id_(?:rsa|dsa|ecdsa|ed25519)\b/, label: 'SSH private key' },
|
|
77
|
+
{ re: /(?:^|[\s"'`/])\.aws\/credentials\b/, label: 'AWS credentials file' },
|
|
78
|
+
{ re: /(?:^|[\s"'`/])\.netrc\b/, label: 'netrc credentials file' },
|
|
79
|
+
{ re: /Library\/Keychains\b/, label: 'macOS keychain' },
|
|
80
|
+
{ re: /(?:^|[\s"'`])\.docker\/config\.json\b/, label: 'Docker registry credentials' },
|
|
81
|
+
{ re: /(?:^|\s)\/etc\/shadow\b/, label: 'system password database' },
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
/** 1 起行号(按 `\n` 计)。 */
|
|
85
|
+
function lineOf(text: string, index: number): number {
|
|
86
|
+
let line = 1
|
|
87
|
+
const end = Math.min(index, text.length)
|
|
88
|
+
for (let i = 0; i < end; i += 1) if (text.charCodeAt(i) === 10) line += 1
|
|
89
|
+
return line
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 首个匹配位置与总数。每次新建带 `g` 的正则,避免 lastIndex 跨调用残留。 */
|
|
93
|
+
function collect(text: string, re: RegExp): { first: number; count: number } {
|
|
94
|
+
const global = new RegExp(re.source, 'gu')
|
|
95
|
+
let first = -1
|
|
96
|
+
let count = 0
|
|
97
|
+
let match: RegExpExecArray | null
|
|
98
|
+
while ((match = global.exec(text)) !== null) {
|
|
99
|
+
if (first === -1) first = match.index
|
|
100
|
+
count += 1
|
|
101
|
+
if (match[0].length === 0) global.lastIndex += 1
|
|
102
|
+
}
|
|
103
|
+
return { first, count }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 审计一段技能文本(SKILL.md 正文、脚本、参考文档都适用)。
|
|
108
|
+
* 纯函数:同样的输入永远给同样的结果,便于单测与「改动前后对比」。
|
|
109
|
+
*/
|
|
110
|
+
export function auditSkillText(relativePath: string, text: string): AuditFinding[] {
|
|
111
|
+
const findings: AuditFinding[] = []
|
|
112
|
+
// 文件开头的 BOM 是合法编码标记,不算零宽隐藏;先剥掉再检测。
|
|
113
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
114
|
+
const push = (rule: AuditRuleId, severity: AuditSeverity, message: string, index: number): void => {
|
|
115
|
+
findings.push({ rule, severity, file: relativePath, line: lineOf(body, index), message })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (ANSI_RE.test(body)) {
|
|
119
|
+
push('ansi-escape', 'block', 'contains a raw ESC control character — it can rewrite what a terminal displays (escape-sequence injection, CWE-150)', body.search(ANSI_RE))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const tags = collect(body, UNICODE_TAG_RE)
|
|
123
|
+
if (tags.count > 0) {
|
|
124
|
+
push('unicode-tag', 'block', `contains ${tags.count} invisible Unicode tag character(s); text hidden there is not rendered by any editor, so a reviewer cannot see what the agent reads`, tags.first)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bidi = collect(body, BIDI_RE)
|
|
128
|
+
if (bidi.count > 0) {
|
|
129
|
+
push('bidi-control', 'block', `contains ${bidi.count} bidirectional control character(s); they can make the file read differently than it executes`, bidi.first)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const zeroWidth = collect(body, ZERO_WIDTH_RE)
|
|
133
|
+
if (zeroWidth.count > 0) {
|
|
134
|
+
push('zero-width', 'warn', `contains ${zeroWidth.count} zero-width character(s); usually a copy-paste artifact, but they can also split a keyword so a reviewer's search misses it`, zeroWidth.first)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const { re, label } of DANGEROUS_COMMANDS) {
|
|
138
|
+
const hit = re.exec(body)
|
|
139
|
+
if (hit !== null && hit.index !== undefined) {
|
|
140
|
+
push('dangerous-command', 'warn', `contains ${label}: \`${hit[0].trim().slice(0, 120)}\``, hit.index)
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const { re, label } of CREDENTIAL_PATHS) {
|
|
146
|
+
const hit = re.exec(body)
|
|
147
|
+
if (hit !== null && hit.index !== undefined) {
|
|
148
|
+
push('credential-path', 'warn', `references ${label}; check the surrounding instructions actually need it`, hit.index)
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return findings
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** description 超出规范上限:部分客户端会截断,技能因此静默失去自动激活能力。 */
|
|
157
|
+
export function auditDescription(file: string, description: string): AuditFinding[] {
|
|
158
|
+
if (description.length <= MAX_DESCRIPTION_CHARS) return []
|
|
159
|
+
return [{
|
|
160
|
+
rule: 'description-too-long',
|
|
161
|
+
severity: 'warn',
|
|
162
|
+
file,
|
|
163
|
+
message: `description is ${description.length} chars; the Agent Skills spec caps it at ${MAX_DESCRIPTION_CHARS} and longer ones get truncated by clients, which silently stops the skill from auto-activating`,
|
|
164
|
+
}]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 一个待落盘/待检查的技能文件。`text` 只在能当作文本读时提供。 */
|
|
168
|
+
export interface AuditBundleFile {
|
|
169
|
+
/** 相对技能目录的路径。 */
|
|
170
|
+
path: string
|
|
171
|
+
size: number
|
|
172
|
+
/** 文本内容;二进制或未下载时省略。 */
|
|
173
|
+
text?: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 审计一个完整技能目录:体积/数量上限 + 每个文本文件的规则扫描。
|
|
178
|
+
* `block` 级发现(体积越界)意味着不该落盘;文本级 `block` 由调用方决定。
|
|
179
|
+
*/
|
|
180
|
+
export function auditSkillBundle(files: readonly AuditBundleFile[], description?: string): AuditFinding[] {
|
|
181
|
+
const findings: AuditFinding[] = []
|
|
182
|
+
if (files.length > MAX_SKILL_FILES) {
|
|
183
|
+
findings.push({
|
|
184
|
+
rule: 'too-many-files',
|
|
185
|
+
severity: 'block',
|
|
186
|
+
file: '.',
|
|
187
|
+
message: `skill has ${files.length} files; the hub imports at most ${MAX_SKILL_FILES} per skill`,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
|
|
191
|
+
if (totalBytes > MAX_SKILL_BUNDLE_BYTES) {
|
|
192
|
+
findings.push({
|
|
193
|
+
rule: 'bundle-too-large',
|
|
194
|
+
severity: 'block',
|
|
195
|
+
file: '.',
|
|
196
|
+
message: `skill is ${formatBytes(totalBytes)} in total; the hub imports at most ${formatBytes(MAX_SKILL_BUNDLE_BYTES)} per skill`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
if (file.size > MAX_SKILL_FILE_BYTES) {
|
|
201
|
+
findings.push({
|
|
202
|
+
rule: 'file-too-large',
|
|
203
|
+
severity: 'block',
|
|
204
|
+
file: file.path,
|
|
205
|
+
message: `${formatBytes(file.size)} is over the ${formatBytes(MAX_SKILL_FILE_BYTES)} per-file limit`,
|
|
206
|
+
})
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (file.text !== undefined) findings.push(...auditSkillText(file.path, file.text))
|
|
210
|
+
}
|
|
211
|
+
if (description !== undefined) findings.push(...auditDescription('SKILL.md', description))
|
|
212
|
+
return findings
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 阻断性发现的单行摘要(导入失败时的错误文本)。 */
|
|
216
|
+
export function blockingReason(findings: readonly AuditFinding[]): string | undefined {
|
|
217
|
+
const blocked = findings.filter((finding) => finding.severity === 'block')
|
|
218
|
+
if (blocked.length === 0) return undefined
|
|
219
|
+
const head = blocked.slice(0, 3).map((finding) => `${finding.file}${finding.line !== undefined ? ':' + String(finding.line) : ''} ${finding.message}`)
|
|
220
|
+
const rest = blocked.length > head.length ? ` (+${blocked.length - head.length} more)` : ''
|
|
221
|
+
return 'skill rejected by content audit: ' + head.join('; ') + rest
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 字节数的人读形式(诊断文案用)。 */
|
|
225
|
+
function formatBytes(bytes: number): string {
|
|
226
|
+
if (bytes < 1024) return `${bytes} B`
|
|
227
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
|
|
228
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
|
|
229
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { foreignRoots, importForeignSkill, scanForeignSkills } from './foreign.ts'
|
|
6
|
+
|
|
7
|
+
describe('foreignRoots', () => {
|
|
8
|
+
it('never lists the hub writable root ~/.agents/skills', () => {
|
|
9
|
+
const dirs = foreignRoots('/home/u').map((root) => root.dir)
|
|
10
|
+
expect(dirs).not.toContain('/home/u/.agents/skills')
|
|
11
|
+
expect(dirs.some((dir) => dir.endsWith('.claude/skills'))).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('scanForeignSkills', () => {
|
|
16
|
+
let home: string
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
home = await mkdtemp(join(tmpdir(), 'dsh-foreign-scan-'))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(home, { recursive: true, force: true })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
async function writeSkill(relativeDir: string, name: string, extra = ''): Promise<void> {
|
|
27
|
+
const dir = join(home, relativeDir)
|
|
28
|
+
await mkdir(dir, { recursive: true })
|
|
29
|
+
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: A ${name} skill for testing\n---\n\nbody\n${extra}`, 'utf8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('finds directory bundles and flat files across agents', async () => {
|
|
33
|
+
await writeSkill('.claude/skills/review', 'review')
|
|
34
|
+
await writeSkill('.codex/skills/plan', 'plan')
|
|
35
|
+
const flatDir = join(home, '.cursor', 'skills')
|
|
36
|
+
await mkdir(flatDir, { recursive: true })
|
|
37
|
+
await writeFile(join(flatDir, 'notes.md'), '---\nname: notes\ndescription: Flat note skill\n---\nbody\n', 'utf8')
|
|
38
|
+
|
|
39
|
+
const skills = await scanForeignSkills(home)
|
|
40
|
+
expect(skills.map((skill) => `${skill.agent}:${skill.name}:${skill.kind}`)).toEqual([
|
|
41
|
+
'cursor:notes:flat',
|
|
42
|
+
'codex:plan:directory',
|
|
43
|
+
'claude-code:review:directory',
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('skips entries whose frontmatter cannot be parsed', async () => {
|
|
48
|
+
const dir = join(home, '.claude', 'skills', 'broken')
|
|
49
|
+
await mkdir(dir, { recursive: true })
|
|
50
|
+
await writeFile(join(dir, 'SKILL.md'), '# no frontmatter\n', 'utf8')
|
|
51
|
+
expect(await scanForeignSkills(home)).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('marks names that already exist in the hub catalog', async () => {
|
|
55
|
+
await writeSkill('.claude/skills/taken', 'taken')
|
|
56
|
+
const skills = await scanForeignSkills(home, new Set(['taken']))
|
|
57
|
+
expect(skills[0]?.existing).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns nothing when no external root exists', async () => {
|
|
61
|
+
expect(await scanForeignSkills(home)).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('importForeignSkill', () => {
|
|
66
|
+
let home: string
|
|
67
|
+
let targetRoot: string
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
home = await mkdtemp(join(tmpdir(), 'dsh-foreign-import-'))
|
|
71
|
+
targetRoot = join(home, 'skills')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
await rm(home, { recursive: true, force: true })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('copies a directory bundle with its support files', async () => {
|
|
79
|
+
const dir = join(home, '.claude', 'skills', 'demo')
|
|
80
|
+
await mkdir(join(dir, 'scripts'), { recursive: true })
|
|
81
|
+
await writeFile(join(dir, 'SKILL.md'), '---\nname: demo\ndescription: Demo skill for tests\n---\nbody\n', 'utf8')
|
|
82
|
+
await writeFile(join(dir, 'scripts', 'run.sh'), 'npm test\n', 'utf8')
|
|
83
|
+
const [skill] = await scanForeignSkills(home)
|
|
84
|
+
const path = await importForeignSkill(skill!, targetRoot)
|
|
85
|
+
expect(path).toBe(join(targetRoot, 'demo', 'SKILL.md'))
|
|
86
|
+
await expect(readFile(path, 'utf8')).resolves.toContain('name: demo')
|
|
87
|
+
await expect(readFile(join(targetRoot, 'demo', 'scripts', 'run.sh'), 'utf8')).resolves.toBe('npm test\n')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('lands a flat skill as a directory bundle', async () => {
|
|
91
|
+
const dir = join(home, '.codex', 'skills')
|
|
92
|
+
await mkdir(dir, { recursive: true })
|
|
93
|
+
await writeFile(join(dir, 'flat.md'), '---\nname: flat\ndescription: Flat skill for tests\n---\nbody\n', 'utf8')
|
|
94
|
+
const [skill] = await scanForeignSkills(home)
|
|
95
|
+
const path = await importForeignSkill(skill!, targetRoot)
|
|
96
|
+
expect(path).toBe(join(targetRoot, 'flat', 'SKILL.md'))
|
|
97
|
+
await expect(readFile(path, 'utf8')).resolves.toContain('name: flat')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('rejects a skill carrying hidden Unicode tag characters', async () => {
|
|
101
|
+
const dir = join(home, '.claude', 'skills', 'sneaky')
|
|
102
|
+
await mkdir(dir, { recursive: true })
|
|
103
|
+
const body = '---\nname: sneaky\ndescription: Looks fine\n---\nbody' + String.fromCodePoint(0xe0041) + '\n'
|
|
104
|
+
await writeFile(join(dir, 'SKILL.md'), body, 'utf8')
|
|
105
|
+
const [skill] = await scanForeignSkills(home)
|
|
106
|
+
await expect(importForeignSkill(skill!, targetRoot)).rejects.toThrow(/invisible Unicode tag/)
|
|
107
|
+
await expect(readFile(join(targetRoot, 'sneaky', 'SKILL.md'), 'utf8')).rejects.toThrow()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not follow symlinks out of the external skill', async () => {
|
|
111
|
+
const dir = join(home, '.claude', 'skills', 'linked')
|
|
112
|
+
await mkdir(dir, { recursive: true })
|
|
113
|
+
await writeFile(join(dir, 'SKILL.md'), '---\nname: linked\ndescription: Linked skill for tests\n---\nbody\n', 'utf8')
|
|
114
|
+
const secret = join(home, 'secret.txt')
|
|
115
|
+
await writeFile(secret, 'private\n', 'utf8')
|
|
116
|
+
await symlink(secret, join(dir, 'leak.txt'))
|
|
117
|
+
const [skill] = await scanForeignSkills(home)
|
|
118
|
+
await importForeignSkill(skill!, targetRoot)
|
|
119
|
+
await expect(readFile(join(targetRoot, 'linked', 'leak.txt'), 'utf8')).rejects.toThrow()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部 agent 技能的只读发现与收编。
|
|
3
|
+
*
|
|
4
|
+
* SKILL.md 已经是跨 agent 的事实标准(Claude Code / Codex / Cursor / Gemini CLI
|
|
5
|
+
* 等 45+ 产品读同一份文件),但**发现路径各家不同**。于是「我在 Claude Code 里
|
|
6
|
+
* 调好的技能」在 dsh 里看不见,用户只能手工复制目录。
|
|
7
|
+
*
|
|
8
|
+
* 这里只做两件事:读(列出别处有什么)和复制(把选中的收编到本地可写根)。
|
|
9
|
+
* 外部目录一律**只读**,绝不改写 —— 那是别的 agent 的资产。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
14
|
+
import { parseFrontmatter } from './frontmatter.ts'
|
|
15
|
+
import { scanRoot } from './scan.ts'
|
|
16
|
+
import { auditSkillBundle, blockingReason, MAX_SKILL_FILES, type AuditBundleFile } from './audit.ts'
|
|
17
|
+
|
|
18
|
+
/** 一个外部 agent 的技能根。`agent` 是展示用的稳定标识。 */
|
|
19
|
+
export interface ForeignRoot {
|
|
20
|
+
agent: string
|
|
21
|
+
/** 绝对路径;不存在时扫描直接返回空。 */
|
|
22
|
+
dir: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一条可收编的外部技能。 */
|
|
26
|
+
export interface ForeignSkill {
|
|
27
|
+
/** 技能名(目录名或扁平文件名,与 frontmatter 一致时才算有效)。 */
|
|
28
|
+
name: string
|
|
29
|
+
description: string
|
|
30
|
+
/** 来源 agent 标识。 */
|
|
31
|
+
agent: string
|
|
32
|
+
/** 发现文件绝对路径。 */
|
|
33
|
+
path: string
|
|
34
|
+
/** 资源基目录(目录包本身,或扁平文件的父目录)。 */
|
|
35
|
+
directory: string
|
|
36
|
+
kind: 'directory' | 'flat'
|
|
37
|
+
/** 目标根已有同名技能(不可导入)。 */
|
|
38
|
+
existing: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 常见 agent 的用户级技能目录。`~/.agents/skills` 不在此列 —— 那是 hub 自己的
|
|
43
|
+
* 可写根(user-agents),重复列会让同一个技能出现两次。
|
|
44
|
+
* 路径取自 vercel-labs/skills 的 agent 注册表,只保留桌面端常见的几个。
|
|
45
|
+
*/
|
|
46
|
+
export function foreignRoots(userHome: string): ForeignRoot[] {
|
|
47
|
+
return [
|
|
48
|
+
{ agent: 'claude-code', dir: join(userHome, '.claude', 'skills') },
|
|
49
|
+
{ agent: 'codex', dir: join(userHome, '.codex', 'skills') },
|
|
50
|
+
{ agent: 'cursor', dir: join(userHome, '.cursor', 'skills') },
|
|
51
|
+
{ agent: 'gemini-cli', dir: join(userHome, '.gemini', 'skills') },
|
|
52
|
+
{ agent: 'copilot', dir: join(userHome, '.copilot', 'skills') },
|
|
53
|
+
{ agent: 'opencode', dir: join(userHome, '.config', 'opencode', 'skills') },
|
|
54
|
+
{ agent: 'goose', dir: join(userHome, '.config', 'goose', 'skills') },
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 扫描所有外部根。解析不出 frontmatter 的条目直接跳过 —— 它们在任何 agent 里
|
|
60
|
+
* 都不会生效,列出来只会让人以为「导入就好了」。
|
|
61
|
+
*/
|
|
62
|
+
export async function scanForeignSkills(userHome: string, existing: ReadonlySet<string> = new Set()): Promise<ForeignSkill[]> {
|
|
63
|
+
const found: ForeignSkill[] = []
|
|
64
|
+
for (const root of foreignRoots(userHome)) {
|
|
65
|
+
for (const entry of await scanRoot(root.dir)) {
|
|
66
|
+
let text: string
|
|
67
|
+
try {
|
|
68
|
+
text = await readFile(entry.path, 'utf8')
|
|
69
|
+
} catch {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const parsed = parseFrontmatter(text)
|
|
73
|
+
if ('error' in parsed) continue
|
|
74
|
+
const name = entry.kind === 'directory' ? basename(entry.directory) : basename(entry.path, '.md')
|
|
75
|
+
found.push({
|
|
76
|
+
name,
|
|
77
|
+
description: parsed.value.description,
|
|
78
|
+
agent: root.agent,
|
|
79
|
+
path: entry.path,
|
|
80
|
+
directory: entry.directory,
|
|
81
|
+
kind: entry.kind,
|
|
82
|
+
existing: existing.has(name),
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return found.sort((a, b) => (a.name === b.name ? a.agent.localeCompare(b.agent) : a.name.localeCompare(b.name)))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface CollectedFile {
|
|
90
|
+
/** 相对技能目录的路径。 */
|
|
91
|
+
path: string
|
|
92
|
+
absolute: string
|
|
93
|
+
size: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 递归列文件。符号链接不跟随(lstat):一个指向 `~/.ssh` 的链接不该因为
|
|
98
|
+
* 「用户点了导入」就被复制进技能目录。
|
|
99
|
+
*/
|
|
100
|
+
async function walk(dir: string, base: string, depth: number, out: CollectedFile[]): Promise<void> {
|
|
101
|
+
if (depth > 8 || out.length >= MAX_SKILL_FILES) return
|
|
102
|
+
let names: string[]
|
|
103
|
+
try {
|
|
104
|
+
names = await readdir(dir)
|
|
105
|
+
} catch {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
names.sort()
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
if (out.length >= MAX_SKILL_FILES) return
|
|
111
|
+
if (name === '.git') continue
|
|
112
|
+
const absolute = join(dir, name)
|
|
113
|
+
let info
|
|
114
|
+
try {
|
|
115
|
+
info = await lstat(absolute)
|
|
116
|
+
} catch {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (info.isSymbolicLink()) continue
|
|
120
|
+
if (info.isDirectory()) await walk(absolute, base, depth + 1, out)
|
|
121
|
+
else if (info.isFile()) out.push({ path: relative(base, absolute), absolute, size: info.size })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function collectForCopy(skill: ForeignSkill): Promise<CollectedFile[]> {
|
|
126
|
+
if (skill.kind === 'flat') {
|
|
127
|
+
const info = await lstat(skill.path)
|
|
128
|
+
return [{ path: basename(skill.path), absolute: skill.path, size: info.size }]
|
|
129
|
+
}
|
|
130
|
+
const out: CollectedFile[] = []
|
|
131
|
+
await walk(skill.directory, skill.directory, 0, out)
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 文本文件的判定与市场导入一致:无 NUL、解码无替换字符。 */
|
|
136
|
+
function asText(buffer: Buffer): string | undefined {
|
|
137
|
+
if (buffer.includes(0)) return undefined
|
|
138
|
+
const text = buffer.toString('utf8')
|
|
139
|
+
return text.includes('\uFFFD') ? undefined : text
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 把一个外部技能收编到 `targetRoot`(通常是 `~/.dsh/skills`)。
|
|
144
|
+
*
|
|
145
|
+
* 与市场导入同一套闸:体积/数量上限 + 内容审计,命中阻断级直接拒绝;
|
|
146
|
+
* 落盘仍是「临时目录 → 校验 → 原子 rename」,失败不留半成品。
|
|
147
|
+
* 扁平技能统一落成目录包 —— hub 的所有写入路径都建目录包,两种形态并存会
|
|
148
|
+
* 触发同名不可达(issue #16 那一类)。
|
|
149
|
+
*/
|
|
150
|
+
export async function importForeignSkill(skill: ForeignSkill, targetRoot: string): Promise<string> {
|
|
151
|
+
const collected = await collectForCopy(skill)
|
|
152
|
+
if (collected.length === 0) throw new Error('skill has no files to import')
|
|
153
|
+
|
|
154
|
+
const audited: AuditBundleFile[] = []
|
|
155
|
+
const payload: Array<{ relative: string; buffer: Buffer }> = []
|
|
156
|
+
for (const file of collected) {
|
|
157
|
+
const buffer = await readFile(file.absolute)
|
|
158
|
+
const text = asText(buffer)
|
|
159
|
+
audited.push({ path: file.path, size: buffer.length, ...(text !== undefined ? { text } : {}) })
|
|
160
|
+
payload.push({ relative: file.path, buffer })
|
|
161
|
+
}
|
|
162
|
+
const blocked = blockingReason(auditSkillBundle(audited))
|
|
163
|
+
if (blocked !== undefined) throw new Error(blocked)
|
|
164
|
+
|
|
165
|
+
await mkdir(targetRoot, { recursive: true })
|
|
166
|
+
const tempDir = await mkdtemp(join(targetRoot, '.' + skill.name + '.foreign-'))
|
|
167
|
+
try {
|
|
168
|
+
for (const file of payload) {
|
|
169
|
+
const target = join(tempDir, file.relative)
|
|
170
|
+
await mkdir(dirname(target), { recursive: true })
|
|
171
|
+
await writeFile(target, file.buffer)
|
|
172
|
+
}
|
|
173
|
+
const discovery = skill.kind === 'flat' ? join(tempDir, basename(skill.path)) : join(tempDir, 'SKILL.md')
|
|
174
|
+
let text: string
|
|
175
|
+
try {
|
|
176
|
+
text = await readFile(discovery, 'utf8')
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error('imported skill has no SKILL.md')
|
|
179
|
+
}
|
|
180
|
+
const parsed = parseFrontmatter(text)
|
|
181
|
+
if ('error' in parsed) throw new Error('imported skill rejected: ' + parsed.error)
|
|
182
|
+
if (parsed.value.name !== skill.name) {
|
|
183
|
+
throw new Error(`skill declares name "${parsed.value.name}", expected "${skill.name}"`)
|
|
184
|
+
}
|
|
185
|
+
// 扁平技能落成目录包:`<name>.md` → `<name>/SKILL.md`,让两条写入路径归一。
|
|
186
|
+
if (skill.kind === 'flat') {
|
|
187
|
+
await mkdir(join(tempDir, skill.name), { recursive: true })
|
|
188
|
+
await rename(discovery, join(tempDir, skill.name, 'SKILL.md'))
|
|
189
|
+
}
|
|
190
|
+
const targetDir = join(targetRoot, skill.name)
|
|
191
|
+
await rename(skill.kind === 'flat' ? join(tempDir, skill.name) : tempDir, targetDir)
|
|
192
|
+
return join(targetDir, 'SKILL.md')
|
|
193
|
+
} finally {
|
|
194
|
+
// 成功后 tempDir 只剩空壳(目录包被整体移走),失败时里面还有残留;
|
|
195
|
+
// 一条 force 清理覆盖两种情况。
|
|
196
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { hashSkillAt, hashSkillDirectory } from './hash.ts'
|
|
6
|
+
|
|
7
|
+
describe('hashSkillDirectory', () => {
|
|
8
|
+
let dir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hash-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(dir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
async function write(relative: string, content: string): Promise<void> {
|
|
19
|
+
const full = join(dir, relative)
|
|
20
|
+
await mkdir(join(full, '..'), { recursive: true })
|
|
21
|
+
await writeFile(full, content, 'utf8')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('is stable across calls for identical content', async () => {
|
|
25
|
+
await write('SKILL.md', '---\nname: demo\n---\nbody\n')
|
|
26
|
+
await write('scripts/run.sh', 'npm test\n')
|
|
27
|
+
const first = await hashSkillDirectory(dir)
|
|
28
|
+
const second = await hashSkillDirectory(dir)
|
|
29
|
+
expect(first).toBeDefined()
|
|
30
|
+
expect(first).toBe(second)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('changes when a file body changes', async () => {
|
|
34
|
+
await write('SKILL.md', 'body one\n')
|
|
35
|
+
const before = await hashSkillDirectory(dir)
|
|
36
|
+
await write('SKILL.md', 'body two\n')
|
|
37
|
+
expect(await hashSkillDirectory(dir)).not.toBe(before)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('changes when a file is added', async () => {
|
|
41
|
+
await write('SKILL.md', 'body\n')
|
|
42
|
+
const before = await hashSkillDirectory(dir)
|
|
43
|
+
await write('extra.md', 'more\n')
|
|
44
|
+
expect(await hashSkillDirectory(dir)).not.toBe(before)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('changes when a path is renamed but content is identical', async () => {
|
|
48
|
+
await write('SKILL.md', 'body\n')
|
|
49
|
+
await write('a.md', 'same\n')
|
|
50
|
+
const before = await hashSkillDirectory(dir)
|
|
51
|
+
await rm(join(dir, 'a.md'))
|
|
52
|
+
await write('b.md', 'same\n')
|
|
53
|
+
expect(await hashSkillDirectory(dir)).not.toBe(before)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns undefined for an empty directory', async () => {
|
|
57
|
+
expect(await hashSkillDirectory(dir)).toBeUndefined()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns undefined for a missing directory', async () => {
|
|
61
|
+
expect(await hashSkillDirectory(join(dir, 'nope'))).toBeUndefined()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('ignores .git and .trash', async () => {
|
|
65
|
+
await write('SKILL.md', 'body\n')
|
|
66
|
+
const before = await hashSkillDirectory(dir)
|
|
67
|
+
await write('.git/config', 'noise\n')
|
|
68
|
+
await write('.trash/old/SKILL.md', 'junk\n')
|
|
69
|
+
expect(await hashSkillDirectory(dir)).toBe(before)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not follow symlinks out of the skill directory', async () => {
|
|
73
|
+
await write('SKILL.md', 'body\n')
|
|
74
|
+
const before = await hashSkillDirectory(dir)
|
|
75
|
+
const outside = await mkdtemp(join(tmpdir(), 'dsh-skill-hash-out-'))
|
|
76
|
+
try {
|
|
77
|
+
await writeFile(join(outside, 'secret.txt'), 'do not read me\n', 'utf8')
|
|
78
|
+
await symlink(join(outside, 'secret.txt'), join(dir, 'link.txt'))
|
|
79
|
+
expect(await hashSkillDirectory(dir)).toBe(before)
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(outside, { recursive: true, force: true })
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('hashSkillAt', () => {
|
|
87
|
+
let dir: string
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hash-at-'))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
await rm(dir, { recursive: true, force: true })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('hashes a directory bundle through its SKILL.md path', async () => {
|
|
98
|
+
const bundle = join(dir, 'demo')
|
|
99
|
+
await mkdir(bundle)
|
|
100
|
+
await writeFile(join(bundle, 'SKILL.md'), 'bundle body\n', 'utf8')
|
|
101
|
+
expect(await hashSkillAt(join(bundle, 'SKILL.md'))).toBe(await hashSkillDirectory(bundle))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('hashes a disabled bundle through SKILL.md.disabled', async () => {
|
|
105
|
+
const bundle = join(dir, 'demo')
|
|
106
|
+
await mkdir(bundle)
|
|
107
|
+
await writeFile(join(bundle, 'SKILL.md.disabled'), 'bundle body\n', 'utf8')
|
|
108
|
+
expect(await hashSkillAt(join(bundle, 'SKILL.md.disabled'))).toBe(await hashSkillDirectory(bundle))
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('hashes a flat skill file directly', async () => {
|
|
112
|
+
const flat = join(dir, 'flat.md')
|
|
113
|
+
await writeFile(flat, 'flat body\n', 'utf8')
|
|
114
|
+
const first = await hashSkillAt(flat)
|
|
115
|
+
expect(first).toBeDefined()
|
|
116
|
+
await writeFile(flat, 'flat body changed\n', 'utf8')
|
|
117
|
+
expect(await hashSkillAt(flat)).not.toBe(first)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('treats a flat .md.disabled file as a flat skill', async () => {
|
|
121
|
+
const flat = join(dir, 'flat.md.disabled')
|
|
122
|
+
await writeFile(flat, 'flat body\n', 'utf8')
|
|
123
|
+
expect(await hashSkillAt(flat)).toBeDefined()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns undefined when the discovery file is missing', async () => {
|
|
127
|
+
expect(await hashSkillAt(join(dir, 'ghost', 'SKILL.md'))).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
})
|