dsh-skill-hub 0.2.6 → 0.3.0
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/README.md +3 -1
- package/README.zh.md +3 -1
- package/lib/client.js +1249 -407
- package/lib/client.js.map +1 -1
- package/lib/index.js +439 -81
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/panel/ScenesView.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +1 -1
- package/lib/types/client/panel/useSkillHub.d.ts +20 -2
- package/lib/types/protocol.d.ts +93 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +1 -1
- package/src/client/api.ts +61 -1
- package/src/client/locales.ts +26 -0
- package/src/client/panel/MarketView.tsx +201 -47
- package/src/client/panel/ScenesView.tsx +27 -2
- package/src/client/panel/SkillHubPanel.tsx +18 -19
- package/src/client/panel/SkillRow.tsx +8 -8
- package/src/client/panel/SourcesView.tsx +203 -26
- package/src/client/panel/panel.module.css +17 -6
- package/src/client/panel/useSkillHub.ts +219 -12
- package/src/index.ts +18 -0
- package/src/protocol.ts +89 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +18 -4
- package/src/routes.ts +269 -47
- package/src/skillfs.ts +17 -4
- package/src/store.ts +72 -4
package/src/index.ts
CHANGED
|
@@ -21,6 +21,10 @@ import { SkillHubProvider } from './provider.ts'
|
|
|
21
21
|
import { makeRoutes } from './routes.ts'
|
|
22
22
|
import { createSkillStatsReader, type SkillStatsReader } from './stats.ts'
|
|
23
23
|
import { SkillHubStore } from './store.ts'
|
|
24
|
+
import { cleanupLeftoverImportDirs } from './repo.ts'
|
|
25
|
+
import { dshHome } from './store.ts'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { homedir } from 'node:os'
|
|
24
28
|
|
|
25
29
|
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
26
30
|
export const name = 'skill-hub'
|
|
@@ -213,6 +217,20 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
213
217
|
// build seeds the settings namespace from the saved sidecar config when the
|
|
214
218
|
// namespace has no user section yet. Later edits live only in the settings
|
|
215
219
|
// document; the sidecar keeps its (now-stale) copy untouched.
|
|
220
|
+
// Startup: 清理 Issue #3 遗留的 .*.import-* 临时目录(尽早回收,不阻塞)
|
|
221
|
+
void (async () => {
|
|
222
|
+
const home = dshHome()
|
|
223
|
+
const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')
|
|
224
|
+
for (const root of [join(home, 'skills'), join(agentsHome, 'skills')]) {
|
|
225
|
+
try {
|
|
226
|
+
const c = await cleanupLeftoverImportDirs(root)
|
|
227
|
+
if (c > 0) ctx.logger.info(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
ctx.logger.warn('[dsh-skill-hub] startup cleanup failed', error)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
})()
|
|
233
|
+
|
|
216
234
|
void (async () => {
|
|
217
235
|
const legacy = await store.getConfig()
|
|
218
236
|
if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
|
package/src/protocol.ts
CHANGED
|
@@ -23,11 +23,16 @@ export const SKILL_HUB_API = {
|
|
|
23
23
|
marketSync: '/api/skill-hub/market/source/sync',
|
|
24
24
|
repo: '/api/skill-hub/repo',
|
|
25
25
|
repoImport: '/api/skill-hub/repo/import',
|
|
26
|
+
repoImportProgress: '/api/skill-hub/repo/import/progress',
|
|
27
|
+
repoImportCancel: '/api/skill-hub/repo/import/cancel',
|
|
26
28
|
update: '/api/skill-hub/update',
|
|
27
29
|
groups: '/api/skill-hub/groups',
|
|
28
30
|
tag: '/api/skill-hub/tag',
|
|
29
31
|
tagDelete: '/api/skill-hub/tag/delete',
|
|
30
32
|
tagMembers: '/api/skill-hub/tag/members',
|
|
33
|
+
tagReorder: '/api/skill-hub/tag/reorder',
|
|
34
|
+
collectionReorder: '/api/skill-hub/collections/reorder',
|
|
35
|
+
sourceGroupReorder: '/api/skill-hub/source-groups/reorder',
|
|
31
36
|
sources: '/api/skill-hub/sources',
|
|
32
37
|
sourceCheck: '/api/skill-hub/sources/check',
|
|
33
38
|
sourceSync: '/api/skill-hub/sources/sync',
|
|
@@ -414,7 +419,7 @@ export interface MarketSyncResponse {
|
|
|
414
419
|
skills: string[]
|
|
415
420
|
}
|
|
416
421
|
|
|
417
|
-
/** A skill discovered in a GitHub repo under skills
|
|
422
|
+
/** A skill discovered in a GitHub repo under any top-level root (e.g. skills/, templates/). */
|
|
418
423
|
export interface RepoSkillEntry {
|
|
419
424
|
/** Kebab-case skill name (directory basename). */
|
|
420
425
|
name: string
|
|
@@ -434,8 +439,8 @@ export interface RepoSkillEntry {
|
|
|
434
439
|
existing: boolean
|
|
435
440
|
}
|
|
436
441
|
|
|
437
|
-
/**
|
|
438
|
-
export type RepoRoot =
|
|
442
|
+
/** Skill root in a GitHub repo: the top-level directory that contains skills (e.g. skills, design-templates, templates). Auto-derived from SKILL.md locations, not hard-coded. */
|
|
443
|
+
export type RepoRoot = string
|
|
439
444
|
|
|
440
445
|
/** GET /api/skill-hub/repo — discover importable skills in a GitHub repo. */
|
|
441
446
|
export interface RepoDiscoverResponse {
|
|
@@ -461,8 +466,49 @@ export interface RepoImportRequest {
|
|
|
461
466
|
ref?: string
|
|
462
467
|
}
|
|
463
468
|
|
|
464
|
-
/** POST /api/skill-hub/repo/import */
|
|
469
|
+
/** POST /api/skill-hub/repo/import — now creates an async job (B方案) */
|
|
465
470
|
export interface RepoImportResponse {
|
|
471
|
+
ok: true
|
|
472
|
+
jobId: string
|
|
473
|
+
total: number
|
|
474
|
+
totalBytes: number
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** GET /api/skill-hub/repo/import/progress?jobId=xxx */
|
|
478
|
+
export interface RepoImportProgressResponse {
|
|
479
|
+
ok: true
|
|
480
|
+
jobId: string
|
|
481
|
+
status: 'running' | 'done' | 'cancelled' | 'error'
|
|
482
|
+
total: number
|
|
483
|
+
done: number
|
|
484
|
+
/** 当前正在下载的 skill 名,done < total 时有效 */
|
|
485
|
+
current?: string
|
|
486
|
+
/** 当前正在下载的文件路径(相对 skill dir) */
|
|
487
|
+
currentFile?: string
|
|
488
|
+
/** 字节级进度 */
|
|
489
|
+
totalBytes: number
|
|
490
|
+
downloadedBytes: number
|
|
491
|
+
/** 下载速度 bytes/sec,running 时有效 */
|
|
492
|
+
bytesPerSecond?: number
|
|
493
|
+
imported: Array<{ name: string; origin: string; path: string }>
|
|
494
|
+
skipped: Array<{ name: string; reason: 'exists' }>
|
|
495
|
+
failed: Array<{ name: string; error: string }>
|
|
496
|
+
/** error 状态时的原因 */
|
|
497
|
+
error?: string
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** POST /api/skill-hub/repo/import/cancel */
|
|
501
|
+
export interface RepoImportCancelRequest {
|
|
502
|
+
jobId: string
|
|
503
|
+
}
|
|
504
|
+
export interface RepoImportCancelResponse {
|
|
505
|
+
ok: true
|
|
506
|
+
jobId: string
|
|
507
|
+
status: 'cancelled' | 'done'
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @deprecated 旧同步响应,保留供类型兼容,实际已由 Progress 代替 */
|
|
511
|
+
export interface RepoImportLegacyResponse {
|
|
466
512
|
ok: true
|
|
467
513
|
imported: Array<{ name: string; origin: string; path: string }>
|
|
468
514
|
skipped: Array<{ name: string; reason: 'exists' }>
|
|
@@ -507,12 +553,16 @@ export interface CollectionGroup {
|
|
|
507
553
|
/** GET /api/skill-hub/groups */
|
|
508
554
|
export interface GroupsResponse {
|
|
509
555
|
ok: true
|
|
510
|
-
/** 用户自定义 tag
|
|
556
|
+
/** 用户自定义 tag 分组(按拖拽顺序)。 */
|
|
511
557
|
tags: SkillTag[]
|
|
512
|
-
/**
|
|
558
|
+
/** 系统集合组(按拖拽顺序,兜底按名称)。 */
|
|
513
559
|
collections: CollectionGroup[]
|
|
514
560
|
/** skillName → 集合名 的完整映射。 */
|
|
515
561
|
origins: Record<string, string>
|
|
562
|
+
/** 来源分组整体顺序(project / col:xxx / uncategorized-source),用于 SourcesView 顶层排序 */
|
|
563
|
+
sourceGroupOrder?: string[]
|
|
564
|
+
/** 兼容旧 collectionOrder 字段 */
|
|
565
|
+
collectionOrder?: string[]
|
|
516
566
|
}
|
|
517
567
|
|
|
518
568
|
/** POST /api/skill-hub/tag — 新建(缺省 id)或重命名(带 id)。 */
|
|
@@ -554,6 +604,39 @@ export interface TagMembersResponse {
|
|
|
554
604
|
tags: SkillTag[]
|
|
555
605
|
}
|
|
556
606
|
|
|
607
|
+
/** POST /api/skill-hub/tag/reorder — 拖拽重排场景分组 */
|
|
608
|
+
export interface TagReorderRequest {
|
|
609
|
+
/** 按新顺序排列的 tag id 列表(需包含全部 id) */
|
|
610
|
+
orderedIds: string[]
|
|
611
|
+
}
|
|
612
|
+
/** POST /api/skill-hub/tag/reorder */
|
|
613
|
+
export interface TagReorderResponse {
|
|
614
|
+
ok: true
|
|
615
|
+
tags: SkillTag[]
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** POST /api/skill-hub/collections/reorder — 拖拽重排来源集合 */
|
|
619
|
+
export interface CollectionReorderRequest {
|
|
620
|
+
/** 按新顺序排列的集合名列表(需包含全部 name) */
|
|
621
|
+
orderedNames: string[]
|
|
622
|
+
}
|
|
623
|
+
/** POST /api/skill-hub/collections/reorder */
|
|
624
|
+
export interface CollectionReorderResponse {
|
|
625
|
+
ok: true
|
|
626
|
+
collections: CollectionGroup[]
|
|
627
|
+
order: string[]
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** POST /api/skill-hub/source-groups/reorder — 拖拽重排来源顶层分组(project / collections / personal) */
|
|
631
|
+
export interface SourceGroupReorderRequest {
|
|
632
|
+
/** 按新顺序排列的顶层分组 key 列表(project / col:xxx / uncategorized-source) */
|
|
633
|
+
orderedKeys: string[]
|
|
634
|
+
}
|
|
635
|
+
export interface SourceGroupReorderResponse {
|
|
636
|
+
ok: true
|
|
637
|
+
order: string[]
|
|
638
|
+
}
|
|
639
|
+
|
|
557
640
|
/** 来源跟踪记录:一组来自同一上游仓库根目录的技能。 */
|
|
558
641
|
export interface SourceRecord {
|
|
559
642
|
/** GitHub owner/repo。 */
|
package/src/repo.test.ts
CHANGED
|
@@ -40,10 +40,16 @@ describe('originForRoot', () => {
|
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
it('splits by root when multiple roots are present', () => {
|
|
43
|
-
const roots = new Set(['skills', 'design-templates']
|
|
43
|
+
const roots = new Set(['skills', 'design-templates'])
|
|
44
44
|
expect(originForRoot('nexu-io/open-design', roots, 'skills')).toBe('nexu-io/open-design/skills')
|
|
45
45
|
expect(originForRoot('nexu-io/open-design', roots, 'design-templates')).toBe('nexu-io/open-design/design-templates')
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
it('works with arbitrary roots', () => {
|
|
49
|
+
const roots = new Set(['templates', 'workflows'])
|
|
50
|
+
expect(originForRoot('a/b', roots, 'templates')).toBe('a/b/templates')
|
|
51
|
+
expect(originForRoot('a/b', new Set(['my-root']), 'my-root')).toBe('a/b')
|
|
52
|
+
})
|
|
47
53
|
})
|
|
48
54
|
|
|
49
55
|
describe('collectRepoSkillFiles', () => {
|
|
@@ -59,7 +65,7 @@ describe('collectRepoSkillFiles', () => {
|
|
|
59
65
|
})
|
|
60
66
|
|
|
61
67
|
describe('discoverRepoEntries', () => {
|
|
62
|
-
it('discovers skills and design-templates and computes origins/sizes', () => {
|
|
68
|
+
it('discovers skills and design-templates and computes origins/sizes (generic roots)', () => {
|
|
63
69
|
const tree = [
|
|
64
70
|
blob('skills/engineering/code-review/SKILL.md', 10),
|
|
65
71
|
blob('skills/engineering/code-review/README.md', 5),
|
|
@@ -70,55 +76,85 @@ describe('discoverRepoEntries', () => {
|
|
|
70
76
|
blob('plugins/examples/foo/SKILL.md', 99),
|
|
71
77
|
]
|
|
72
78
|
const entries = discoverRepoEntries(tree, 'nexu-io/open-design', new Set(['tdd']))
|
|
73
|
-
|
|
74
|
-
expect(entries
|
|
75
|
-
expect(entries
|
|
76
|
-
expect(entries
|
|
77
|
-
expect(entries
|
|
78
|
-
expect(entries
|
|
79
|
-
expect(entries
|
|
80
|
-
expect(entries
|
|
79
|
+
// generic: any top-level dir is a root, so docs/plugins are also discovered (4 roots -> split origins)
|
|
80
|
+
expect(entries.map((entry) => entry.name)).toEqual(['code-review', 'dashboard', 'example', 'foo', 'tdd'])
|
|
81
|
+
expect(entries.find((e) => e.name === 'code-review')?.origin).toBe('nexu-io/open-design/skills')
|
|
82
|
+
expect(entries.find((e) => e.name === 'code-review')?.fileCount).toBe(2)
|
|
83
|
+
expect(entries.find((e) => e.name === 'code-review')?.totalBytes).toBe(15)
|
|
84
|
+
expect(entries.find((e) => e.name === 'dashboard')?.origin).toBe('nexu-io/open-design/design-templates')
|
|
85
|
+
expect(entries.find((e) => e.name === 'dashboard')?.fileCount).toBe(2)
|
|
86
|
+
expect(entries.find((e) => e.name === 'dashboard')?.totalBytes).toBe(109)
|
|
87
|
+
expect(entries.find((e) => e.name === 'example')?.origin).toBe('nexu-io/open-design/docs')
|
|
88
|
+
expect(entries.find((e) => e.name === 'foo')?.origin).toBe('nexu-io/open-design/plugins')
|
|
89
|
+
expect(entries.find((e) => e.name === 'tdd')?.existing).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('discovers arbitrary roots like templates and workflows', () => {
|
|
93
|
+
const tree = [
|
|
94
|
+
blob('templates/my-template/SKILL.md', 10),
|
|
95
|
+
blob('workflows/deploy/SKILL.md', 7),
|
|
96
|
+
blob('my-skills/foo/SKILL.md', 5),
|
|
97
|
+
]
|
|
98
|
+
const entries = discoverRepoEntries(tree, 'a/b')
|
|
99
|
+
expect(entries.map((e) => e.root).sort()).toEqual(['my-skills', 'templates', 'workflows'])
|
|
100
|
+
expect(entries.map((e) => e.name).sort()).toEqual(['deploy', 'foo', 'my-template'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('ignores hidden roots and bare SKILL.md at depth 1', () => {
|
|
104
|
+
const tree = [
|
|
105
|
+
blob('.hidden/secret/SKILL.md', 1),
|
|
106
|
+
blob('SKILL.md', 1),
|
|
107
|
+
blob('skills/SKILL.md', 1),
|
|
108
|
+
blob('.github/workflows/ci/SKILL.md', 1),
|
|
109
|
+
]
|
|
110
|
+
expect(discoverRepoEntries(tree, 'a/b')).toEqual([])
|
|
81
111
|
})
|
|
82
112
|
|
|
83
|
-
it('returns empty when no
|
|
113
|
+
it('returns empty when no SKILL.md exists', () => {
|
|
84
114
|
expect(discoverRepoEntries([blob('README.md'), blob('skills/foo/README.md')], 'a/b')).toEqual([])
|
|
85
115
|
})
|
|
86
116
|
})
|
|
87
117
|
|
|
88
118
|
describe('skillDirOf', () => {
|
|
89
119
|
it('recovers the nested upstream directory from the manifest', () => {
|
|
90
|
-
const source = { root: 'skills'
|
|
120
|
+
const source = { root: 'skills', manifest: { 'skills/engineering/ask-matt/SKILL.md': 100, 'skills/engineering/ask-matt/OTHER.md': 5 } }
|
|
91
121
|
expect(skillDirOf(source, 'ask-matt')).toBe('skills/engineering/ask-matt')
|
|
92
122
|
})
|
|
93
123
|
|
|
94
124
|
it('finds the skill in the upstream tree when the manifest lacks an entry', () => {
|
|
95
|
-
const source = { root: 'skills'
|
|
125
|
+
const source = { root: 'skills' }
|
|
96
126
|
const paths = ['skills/engineering/grill-with-docs/SKILL.md', 'skills/engineering/grill-with-docs/agents/openai.yaml']
|
|
97
127
|
expect(skillDirOf(source, 'grill-with-docs', paths)).toBe('skills/engineering/grill-with-docs')
|
|
98
128
|
})
|
|
99
129
|
|
|
100
130
|
it('prefers the top-level-root match over a same-name skill elsewhere', () => {
|
|
101
|
-
const source = { root: 'skills'
|
|
131
|
+
const source = { root: 'skills' }
|
|
102
132
|
const paths = ['docs/engineering/a/SKILL.md', 'skills/misc/a/SKILL.md']
|
|
103
133
|
expect(skillDirOf(source, 'a', paths)).toBe('skills/misc/a')
|
|
104
134
|
})
|
|
105
135
|
|
|
106
136
|
it('follows an upstream move away from the manifest path', () => {
|
|
107
|
-
const source = { root: 'skills'
|
|
137
|
+
const source = { root: 'skills', manifest: { 'skills/old/ask-matt/SKILL.md': 100 } }
|
|
108
138
|
const paths = ['skills/new/ask-matt/SKILL.md']
|
|
109
139
|
expect(skillDirOf(source, 'ask-matt', paths)).toBe('skills/new/ask-matt')
|
|
110
140
|
})
|
|
111
141
|
|
|
112
142
|
it('falls back to root/name without a manifest entry or tree', () => {
|
|
113
|
-
const source = { root: 'skills'
|
|
143
|
+
const source = { root: 'skills' }
|
|
114
144
|
expect(skillDirOf(source, 'plain')).toBe('skills/plain')
|
|
115
145
|
})
|
|
146
|
+
|
|
147
|
+
it('works with arbitrary roots', () => {
|
|
148
|
+
const source = { root: 'templates', manifest: { 'templates/my-tmpl/SKILL.md': 10 } }
|
|
149
|
+
expect(skillDirOf(source, 'my-tmpl')).toBe('templates/my-tmpl')
|
|
150
|
+
expect(skillDirOf({ root: 'workflows' }, 'deploy', ['workflows/deploy/SKILL.md'])).toBe('workflows/deploy')
|
|
151
|
+
})
|
|
116
152
|
})
|
|
117
153
|
|
|
118
154
|
describe('diffRemoteSkills', () => {
|
|
119
155
|
it('matches nested skills via the manifest and reports real deletions only', () => {
|
|
120
156
|
const source = {
|
|
121
|
-
root: 'skills'
|
|
157
|
+
root: 'skills',
|
|
122
158
|
skills: ['ask-matt', 'gone'],
|
|
123
159
|
manifest: {
|
|
124
160
|
'skills/engineering/ask-matt/SKILL.md': 100,
|
|
@@ -134,15 +170,21 @@ describe('diffRemoteSkills', () => {
|
|
|
134
170
|
})
|
|
135
171
|
|
|
136
172
|
it('does not report skills missing from the manifest as deleted when the tree has them', () => {
|
|
137
|
-
const source = { root: 'skills'
|
|
173
|
+
const source = { root: 'skills', skills: ['grill-with-docs'] }
|
|
138
174
|
const tree = [blob('skills/engineering/grill-with-docs/SKILL.md', 100)]
|
|
139
175
|
expect(diffRemoteSkills(tree, source)).toEqual({ updated: ['grill-with-docs'], deleted: [] })
|
|
140
176
|
})
|
|
141
177
|
|
|
142
178
|
it('reports size changes as updated', () => {
|
|
143
|
-
const source = { root: 'skills'
|
|
179
|
+
const source = { root: 'skills', skills: ['a'], manifest: { 'skills/engineering/a/SKILL.md': 100 } }
|
|
144
180
|
expect(diffRemoteSkills([blob('skills/engineering/a/SKILL.md', 120)], source)).toEqual({ updated: ['a'], deleted: [] })
|
|
145
181
|
})
|
|
182
|
+
|
|
183
|
+
it('works with arbitrary roots', () => {
|
|
184
|
+
const source = { root: 'templates', skills: ['x'], manifest: { 'templates/x/SKILL.md': 10 } }
|
|
185
|
+
expect(diffRemoteSkills([blob('templates/x/SKILL.md', 11)], source)).toEqual({ updated: ['x'], deleted: [] })
|
|
186
|
+
expect(diffRemoteSkills([], source)).toEqual({ updated: [], deleted: ['x'] })
|
|
187
|
+
})
|
|
146
188
|
})
|
|
147
189
|
|
|
148
190
|
describe('downloadRepoSkill', () => {
|
package/src/repo.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* GitHub repository skill discovery/import helpers.
|
|
3
3
|
*
|
|
4
4
|
* Kept dependency-free and mostly pure so the root/origin rules are easy to
|
|
5
|
-
* test.
|
|
6
|
-
* `
|
|
7
|
-
* skill
|
|
5
|
+
* test. Roots are auto-derived: any top-level directory that contains a
|
|
6
|
+
* `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
|
|
7
|
+
* `workflows/**`) is treated as a skill root. No hard-coded allowlist.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
@@ -13,9 +13,12 @@ import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
|
13
13
|
import type { RepoRoot, RepoSkillEntry } from './protocol.ts'
|
|
14
14
|
import { parseFrontmatter } from './skillfs.ts'
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/** Preferred display order for known roots; unknown roots sort alphabetically after these. */
|
|
17
17
|
export const REPO_ROOTS: readonly RepoRoot[] = ['skills', 'design-templates']
|
|
18
18
|
|
|
19
|
+
/** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
|
|
20
|
+
const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
21
|
+
|
|
19
22
|
/** Parsed GitHub repository reference. */
|
|
20
23
|
export interface RepoRef {
|
|
21
24
|
owner: string
|
|
@@ -180,20 +183,25 @@ export function collectRepoSkillFiles(tree: readonly RepoTreeItem[], dir: string
|
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
/** Compute an origin collection name. Multiple roots split by root, one root keeps the repo slug. */
|
|
183
|
-
export function originForRoot(repo: string, rootsPresent: ReadonlySet<
|
|
186
|
+
export function originForRoot(repo: string, rootsPresent: ReadonlySet<string>, root: string): string {
|
|
184
187
|
return rootsPresent.size > 1 ? `${repo}/${root}` : repo
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
/** Discover importable skills from a repo tree. Invalid names are ignored. */
|
|
190
|
+
/** Discover importable skills from a repo tree. Invalid names are ignored. Roots are auto-derived from the top-level directory of each SKILL.md. */
|
|
188
191
|
export function discoverRepoEntries(tree: readonly RepoTreeItem[], repo: string, existingNames: ReadonlySet<string> = new Set()): RepoSkillEntry[] {
|
|
189
192
|
const candidates: Array<{ root: RepoRoot; dir: string; name: string; path: string }> = []
|
|
190
|
-
const rootsPresent = new Set<
|
|
193
|
+
const rootsPresent = new Set<string>()
|
|
191
194
|
for (const item of tree) {
|
|
192
195
|
if (item.type !== 'blob') continue
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
// Any SKILL.md at depth >=2: top segment is the root, last segment is the skill name (may be nested like root/category/name/SKILL.md)
|
|
197
|
+
const slash = item.path.indexOf('/')
|
|
198
|
+
if (slash === -1) continue
|
|
199
|
+
if (!item.path.endsWith('/SKILL.md')) continue
|
|
200
|
+
const root = item.path.slice(0, slash)
|
|
201
|
+
if (!ROOT_RE.test(root)) continue
|
|
196
202
|
const dir = item.path.slice(0, -'/SKILL.md'.length)
|
|
203
|
+
// dir must be at least root/name (reject bare root/SKILL.md)
|
|
204
|
+
if (dir === root || dir.length <= root.length + 1) continue
|
|
197
205
|
const name = dir.slice(dir.lastIndexOf('/') + 1)
|
|
198
206
|
if (!isSkillName(name)) continue
|
|
199
207
|
rootsPresent.add(root)
|
|
@@ -231,28 +239,37 @@ export async function mapConcurrent<T, R>(items: readonly T[], limit: number, wo
|
|
|
231
239
|
return results
|
|
232
240
|
}
|
|
233
241
|
|
|
242
|
+
/** Whether an error is an abort (AbortError / signal). */
|
|
243
|
+
export function isAbortError(error: unknown): boolean {
|
|
244
|
+
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted') || error.message.includes('Abort'))
|
|
245
|
+
}
|
|
246
|
+
|
|
234
247
|
/**
|
|
235
248
|
* Download one GitHub file as a buffer. Tries raw.githubusercontent.com
|
|
236
249
|
* first (no API quota); on any failure falls back to the api.github.com
|
|
237
250
|
* contents endpoint with the raw media type (rate-limited, but reachable
|
|
238
251
|
* from networks that block the raw host).
|
|
239
252
|
*/
|
|
240
|
-
export async function downloadGitHubFile(repo: string, ref: string, path: string, fetchImpl: typeof fetch = fetch): Promise<Buffer> {
|
|
253
|
+
export async function downloadGitHubFile(repo: string, ref: string, path: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal): Promise<Buffer> {
|
|
254
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
241
255
|
const encodedPath = path.split('/').map((segment) => encodeURIComponent(segment)).join('/')
|
|
242
256
|
const rawUrl = `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/${encodedPath}`
|
|
243
257
|
let firstError: string | null = null
|
|
244
258
|
let response: Response | null = null
|
|
245
259
|
try {
|
|
246
|
-
response = await fetchImpl(rawUrl, { headers: githubAuthHeaders() })
|
|
260
|
+
response = await fetchImpl(rawUrl, { headers: githubAuthHeaders(), ...(signal !== undefined ? { signal } : {}) })
|
|
247
261
|
} catch (error) {
|
|
262
|
+
if (isAbortError(error)) throw error
|
|
248
263
|
firstError = error instanceof Error ? error.message : String(error)
|
|
249
264
|
}
|
|
250
265
|
if (response === null || !response.ok) {
|
|
266
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
251
267
|
// Fallback: api.github.com/contents with the raw media type.
|
|
252
268
|
const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`
|
|
253
269
|
try {
|
|
254
|
-
response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...githubAuthHeaders() } })
|
|
270
|
+
response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
|
|
255
271
|
} catch (error) {
|
|
272
|
+
if (isAbortError(error)) throw error
|
|
256
273
|
throw new RepoFetchError('download failed: ' + (firstError ?? (error instanceof Error ? error.message : String(error))))
|
|
257
274
|
}
|
|
258
275
|
}
|
|
@@ -262,6 +279,7 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
|
|
|
262
279
|
try {
|
|
263
280
|
return Buffer.from(await response.arrayBuffer())
|
|
264
281
|
} catch (error) {
|
|
282
|
+
if (isAbortError(error)) throw error
|
|
265
283
|
throw new RepoFetchError('download read failed: ' + (error instanceof Error ? error.message : String(error)))
|
|
266
284
|
}
|
|
267
285
|
}
|
|
@@ -278,22 +296,30 @@ export async function downloadRepoSkill(
|
|
|
278
296
|
files: readonly RepoFile[],
|
|
279
297
|
targetRoot: string,
|
|
280
298
|
fetchImpl: typeof fetch = fetch,
|
|
299
|
+
signal?: AbortSignal,
|
|
300
|
+
onProgress?: (bytes: number, file: string) => void,
|
|
281
301
|
): Promise<{ targetDir: string; skillPath: string }> {
|
|
302
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
282
303
|
const targetDir = join(targetRoot, entry.name)
|
|
283
304
|
await mkdir(targetRoot, { recursive: true })
|
|
284
305
|
// Dot-prefixed so a leftover temp dir can never surface as a skill in the
|
|
285
306
|
// provider's discovery scan (scanRoot skips dot entries).
|
|
286
307
|
const tempDir = await mkdtemp(join(targetRoot, '.' + entry.name + '.import-'))
|
|
308
|
+
let renamed = false
|
|
287
309
|
try {
|
|
288
310
|
await mapConcurrent(files, 6, async (file) => {
|
|
311
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
289
312
|
const relative = file.path.slice(entry.dir.length + 1)
|
|
290
313
|
if (relative === '' || relative.includes('..')) throw new RepoFetchError('unsafe repo path: ' + file.path)
|
|
291
314
|
const target = join(tempDir, relative)
|
|
292
|
-
const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl)
|
|
315
|
+
const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal)
|
|
293
316
|
await mkdir(dirname(target), { recursive: true })
|
|
294
317
|
await writeFile(target, buffer)
|
|
318
|
+
onProgress?.(buffer.length, relative)
|
|
295
319
|
})
|
|
296
320
|
|
|
321
|
+
if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
|
322
|
+
|
|
297
323
|
let text: string
|
|
298
324
|
try {
|
|
299
325
|
text = await readFile(join(tempDir, 'SKILL.md'), 'utf8')
|
|
@@ -307,16 +333,53 @@ export async function downloadRepoSkill(
|
|
|
307
333
|
}
|
|
308
334
|
|
|
309
335
|
await rename(tempDir, targetDir)
|
|
336
|
+
renamed = true
|
|
310
337
|
return { targetDir, skillPath: join(targetDir, 'SKILL.md') }
|
|
311
338
|
} catch (error) {
|
|
312
|
-
// Concurrent workers may still be writing when the batch fails; retry
|
|
313
|
-
// the cleanup once after a beat before giving up.
|
|
314
|
-
await rm(tempDir, { recursive: true, force: true }).catch(async () => {
|
|
315
|
-
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
316
|
-
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
317
|
-
})
|
|
318
339
|
throw error
|
|
340
|
+
} finally {
|
|
341
|
+
if (!renamed) {
|
|
342
|
+
// 尽力清理临时目录,失败只打日志,不再静默吞掉;finally 保证 abort/异常都能清理
|
|
343
|
+
try {
|
|
344
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
345
|
+
} catch (firstError) {
|
|
346
|
+
// 并发 worker 可能仍在写入,稍等 60ms 重试一次
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
348
|
+
try {
|
|
349
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
350
|
+
} catch (secondError) {
|
|
351
|
+
console.warn(`[skill-hub] cleanup tempDir failed ${tempDir}:`, secondError instanceof Error ? secondError.message : String(secondError), 'first:', firstError instanceof Error ? firstError.message : String(firstError))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 启动时扫描并清理残留的 `.*.import-*` 临时目录(Issue #3 第4点)。
|
|
360
|
+
* 越积越多的点前缀目录不会显示为 skill,但会占空间,尽早回收。
|
|
361
|
+
*/
|
|
362
|
+
export async function cleanupLeftoverImportDirs(targetRoot: string): Promise<number> {
|
|
363
|
+
const { readdir, rm: rm2 } = await import('node:fs/promises')
|
|
364
|
+
let names: string[]
|
|
365
|
+
try {
|
|
366
|
+
names = await readdir(targetRoot)
|
|
367
|
+
} catch {
|
|
368
|
+
return 0
|
|
369
|
+
}
|
|
370
|
+
let cleaned = 0
|
|
371
|
+
for (const name of names) {
|
|
372
|
+
if (!/^\..*\.import-/.test(name)) continue
|
|
373
|
+
const full = join(targetRoot, name)
|
|
374
|
+
try {
|
|
375
|
+
await rm2(full, { recursive: true, force: true })
|
|
376
|
+
cleaned += 1
|
|
377
|
+
} catch (error) {
|
|
378
|
+
console.warn(`[skill-hub] startup cleanup failed ${full}:`, error instanceof Error ? error.message : String(error))
|
|
379
|
+
}
|
|
319
380
|
}
|
|
381
|
+
if (cleaned > 0) console.warn(`[skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`)
|
|
382
|
+
return cleaned
|
|
320
383
|
}
|
|
321
384
|
|
|
322
385
|
// ------------------------------------------------------- source tracking
|
|
@@ -447,7 +510,7 @@ export function skillManifest(tree: readonly RepoTreeItem[], dir: string): Recor
|
|
|
447
510
|
* resilient to incomplete manifests and upstream moves.
|
|
448
511
|
*/
|
|
449
512
|
export function skillDirOf(
|
|
450
|
-
source: { root:
|
|
513
|
+
source: { root: string; manifest?: Record<string, number> },
|
|
451
514
|
name: string,
|
|
452
515
|
treePaths?: readonly string[],
|
|
453
516
|
): string {
|
|
@@ -473,7 +536,7 @@ export function skillDirOf(
|
|
|
473
536
|
*/
|
|
474
537
|
export function diffRemoteSkills(
|
|
475
538
|
tree: readonly RepoTreeItem[],
|
|
476
|
-
source: { root:
|
|
539
|
+
source: { root: string; skills: readonly string[]; manifest?: Record<string, number> },
|
|
477
540
|
): { updated: string[]; deleted: string[] } {
|
|
478
541
|
const blobs = new Map<string, number>()
|
|
479
542
|
for (const item of tree) {
|
|
@@ -513,7 +576,7 @@ export function diffRemoteSkills(
|
|
|
513
576
|
}
|
|
514
577
|
|
|
515
578
|
/** Minimal RepoSkillEntry for a tracked skill name (sync re-downloads by name). */
|
|
516
|
-
export function repoSkillEntry(name: string, root:
|
|
579
|
+
export function repoSkillEntry(name: string, root: string, repo: string): RepoSkillEntry {
|
|
517
580
|
return {
|
|
518
581
|
name,
|
|
519
582
|
dir: root + '/' + name,
|
package/src/routes.test.ts
CHANGED
|
@@ -488,7 +488,7 @@ describe('skill-hub routes', () => {
|
|
|
488
488
|
expect((del.json() as import('./protocol.ts').MarketSourceResponse).repos).toEqual([{ repo: 'other/repo', ref: 'v2.0.0' }])
|
|
489
489
|
})
|
|
490
490
|
|
|
491
|
-
// ------------------------------------------------------- repo import
|
|
491
|
+
// ------------------------------------------------------- repo import (B方案 job+轮询)
|
|
492
492
|
it('imports repo skills and records the upstream source', async () => {
|
|
493
493
|
const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
|
|
494
494
|
stubFetch([
|
|
@@ -506,9 +506,23 @@ describe('skill-hub routes', () => {
|
|
|
506
506
|
const res = new FakeResponse()
|
|
507
507
|
await routeFor(SKILL_HUB_API.repoImport).handler(fakeReq('POST', SKILL_HUB_API.repoImport, { repo: 'example/repo', paths: ['skills/code-review/SKILL.md'] }), res as never)
|
|
508
508
|
expect(res.status).toBe(200)
|
|
509
|
-
const
|
|
510
|
-
expect(
|
|
511
|
-
expect(
|
|
509
|
+
const created = res.json() as import('./protocol.ts').RepoImportResponse
|
|
510
|
+
expect(created.jobId).toMatch(/^imp_/)
|
|
511
|
+
expect(created.total).toBe(1)
|
|
512
|
+
// 轮询直到完成(后台任务选项2:关面板也继续跑,轮询查进度)
|
|
513
|
+
let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
|
|
514
|
+
for (let i = 0; i < 20; i++) {
|
|
515
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
516
|
+
const progRes = new FakeResponse()
|
|
517
|
+
await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), progRes as never)
|
|
518
|
+
expect(progRes.status).toBe(200)
|
|
519
|
+
prog = progRes.json() as import('./protocol.ts').RepoImportProgressResponse
|
|
520
|
+
if (prog.status !== 'running') break
|
|
521
|
+
}
|
|
522
|
+
expect(prog).toBeDefined()
|
|
523
|
+
expect(prog!.status).toBe('done')
|
|
524
|
+
expect(prog!.imported).toHaveLength(1)
|
|
525
|
+
expect(prog!.imported[0]).toMatchObject({ name: 'code-review', origin: 'example/repo' })
|
|
512
526
|
await expect(access(join(home, 'skills', 'code-review', 'SKILL.md'))).resolves.toBeUndefined()
|
|
513
527
|
const sources = await store.listSources()
|
|
514
528
|
expect(sources).toHaveLength(1)
|