dsh-skill-hub 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2569 -1646
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +988 -400
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -2
  7. package/lib/types/client/api.d.ts +15 -5
  8. package/lib/types/client/helpers.d.ts +10 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +147 -111
  16. package/lib/types/client/panel/DisabledRow.d.ts +2 -0
  17. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  18. package/lib/types/client/panel/MarketView.d.ts +1 -0
  19. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  20. package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
  21. package/lib/types/client/panel/dialogs.d.ts +19 -0
  22. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  23. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  24. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  25. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  26. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  27. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  28. package/lib/types/client/panel/market-format.d.ts +6 -0
  29. package/lib/types/client/panel/useSkillHub.d.ts +54 -71
  30. package/lib/types/client/settings-card.d.ts +20 -17
  31. package/lib/types/client/settings-form.d.ts +6 -1
  32. package/lib/types/protocol/api.d.ts +38 -0
  33. package/lib/types/protocol/catalog.d.ts +155 -0
  34. package/lib/types/protocol/config.d.ts +113 -0
  35. package/lib/types/protocol/groups.d.ts +95 -0
  36. package/lib/types/protocol/market.d.ts +94 -0
  37. package/lib/types/protocol/repo.d.ts +107 -0
  38. package/lib/types/protocol/sources.d.ts +139 -0
  39. package/lib/types/protocol/stats.d.ts +46 -0
  40. package/lib/types/protocol.d.ts +13 -720
  41. package/lib/types/repo/github-client.d.ts +42 -0
  42. package/lib/types/repo.d.ts +9 -22
  43. package/lib/types/routes/catalog.d.ts +7 -0
  44. package/lib/types/routes/config.d.ts +7 -0
  45. package/lib/types/routes/groups.d.ts +7 -0
  46. package/lib/types/routes/helpers.d.ts +145 -0
  47. package/lib/types/routes/market.d.ts +8 -0
  48. package/lib/types/routes/repo-import.d.ts +7 -0
  49. package/lib/types/routes/route-state.d.ts +55 -0
  50. package/lib/types/routes/sources.d.ts +8 -0
  51. package/lib/types/routes.d.ts +5 -34
  52. package/lib/types/skillfs.d.ts +13 -0
  53. package/lib/types/stats.d.ts +3 -2
  54. package/lib/types/store/errors.d.ts +10 -0
  55. package/lib/types/store/migrate.d.ts +44 -0
  56. package/lib/types/store/paths.d.ts +32 -0
  57. package/lib/types/store/store.d.ts +133 -0
  58. package/lib/types/store.d.ts +7 -146
  59. package/package.json +30 -28
  60. package/src/client/SkillHubSettingsCard.tsx +17 -5
  61. package/src/client/api.ts +108 -184
  62. package/src/client/helpers.ts +50 -1
  63. package/src/client/locales/common.ts +102 -0
  64. package/src/client/locales/detail.ts +30 -0
  65. package/src/client/locales/market.ts +144 -0
  66. package/src/client/locales/settings.ts +80 -0
  67. package/src/client/locales/skills.ts +128 -0
  68. package/src/client/locales/sources.ts +68 -0
  69. package/src/client/locales.ts +26 -452
  70. package/src/client/panel/DisabledRow.tsx +22 -7
  71. package/src/client/panel/MarketSourceRow.tsx +108 -0
  72. package/src/client/panel/MarketView.tsx +12 -246
  73. package/src/client/panel/RepoScanCard.tsx +171 -0
  74. package/src/client/panel/ScenesView.tsx +1 -1
  75. package/src/client/panel/SkillDetailView.tsx +35 -11
  76. package/src/client/panel/SkillHubPanel.tsx +42 -4
  77. package/src/client/panel/SkillRow.tsx +5 -2
  78. package/src/client/panel/SourcesView.tsx +2 -17
  79. package/src/client/panel/dialogs.tsx +61 -0
  80. package/src/client/panel/hooks/shared.ts +81 -0
  81. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  82. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  83. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  84. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  85. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  86. package/src/client/panel/market-format.ts +22 -0
  87. package/src/client/panel/panel.module.css +1 -8
  88. package/src/client/panel/useSkillHub.ts +142 -948
  89. package/src/client/settings-card.tsx +51 -42
  90. package/src/client/settings-form.ts +19 -5
  91. package/src/index.ts +14 -4
  92. package/src/protocol/api.ts +38 -0
  93. package/src/protocol/catalog.ts +168 -0
  94. package/src/protocol/config.ts +145 -0
  95. package/src/protocol/groups.ts +106 -0
  96. package/src/protocol/market.ts +100 -0
  97. package/src/protocol/repo.ts +104 -0
  98. package/src/protocol/sources.ts +146 -0
  99. package/src/protocol/stats.ts +42 -0
  100. package/src/protocol.ts +83 -775
  101. package/src/repo/github-client.ts +131 -0
  102. package/src/repo.test.ts +24 -1
  103. package/src/repo.ts +69 -147
  104. package/src/routes/catalog.ts +309 -0
  105. package/src/routes/config.ts +119 -0
  106. package/src/routes/groups.ts +133 -0
  107. package/src/routes/helpers.ts +489 -0
  108. package/src/routes/market.ts +255 -0
  109. package/src/routes/repo-import.ts +267 -0
  110. package/src/routes/route-state.ts +89 -0
  111. package/src/routes/sources.ts +318 -0
  112. package/src/routes.test.ts +42 -0
  113. package/src/routes.ts +21 -1528
  114. package/src/skillfs.ts +188 -3
  115. package/src/stats.test.ts +58 -3
  116. package/src/stats.ts +45 -16
  117. package/src/store/errors.ts +14 -0
  118. package/src/store/migrate.ts +272 -0
  119. package/src/store/paths.ts +43 -0
  120. package/src/store/store.ts +532 -0
  121. package/src/store.test.ts +18 -0
  122. package/src/store.ts +7 -737
package/src/store.ts CHANGED
@@ -10,742 +10,12 @@
10
10
  *
11
11
  * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
12
12
  * atomically (tmp file + rename).
13
+ *
14
+ * 按职责拆分后的重导出入口:老 `from './store.ts'` 写法保持可用,
15
+ * 新代码可按需直引 `from './store/<module>.ts'`。
13
16
  */
14
17
 
15
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
16
- import { homedir } from 'node:os'
17
- import { dirname, join } from 'node:path'
18
- import type { DisabledSkill, HubConfig, MarketSourceRecord, RepoRoot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from './protocol.ts'
19
-
20
- /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
21
- export const DEFAULT_SCENE_NAME = '通用'
22
-
23
- /** Wire shape persisted on disk. */
24
- interface StoreFile {
25
- version: number
26
- disabled: DisabledSkill[]
27
- /** Runtime configuration edited from the web settings card (hub-owned, not settings-service). */
28
- config?: Partial<HubConfig>
29
- /** User-defined tag groups (pure organization; skill files untouched). */
30
- tags?: SkillTag[]
31
- /** Upstream source tracking records (repo + commit snapshot). */
32
- sources?: SourceRecord[]
33
- /** User-added market sources (owner/repo slugs + optional pinned ref). */
34
- marketSources?: MarketSourceRecord[]
35
- /** Trashed skills (removed after upstream deletion, restorable). */
36
- trash?: TrashEntry[]
37
- /** Usage-statistics incremental-scan checkpoint (frozen watermark + totals). */
38
- skillStats?: SkillStatsCheckpoint
39
- /** Drag-reorder: collection name order for 来源分组 */
40
- collectionOrder?: string[]
41
- /** Drag-reorder: 来源顶层分组整体顺序(project / col:xxx / uncategorized-source) */
42
- sourceGroupOrder?: string[]
43
- }
44
-
45
- /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
46
- export function dshHome(): string {
47
- return process.env.DSH_HOME ?? join(homedir(), '.dsh')
48
- }
49
-
50
- /** Resolve the sidecar state path (injectable in tests). */
51
- export function statePath(home = dshHome()): string {
52
- return join(home, 'dsh-skill-hub.json')
53
- }
54
-
55
- /** Current sidecar schema version. Bump on breaking shape changes and add a migration below. */
56
- export const STORE_VERSION = 4
57
-
58
- /**
59
- * Business-rule failure the routes layer can map onto a 4xx status instead
60
- * of a blanket 500: user input is invalid (validation → 400), the target
61
- * does not exist (not-found → 404), or the operation conflicts with an
62
- * invariant (conflict → 409).
63
- */
64
- export class StoreError extends Error {
65
- readonly kind: 'validation' | 'not-found' | 'conflict'
66
- constructor(kind: StoreError['kind'], message: string) {
67
- super(message)
68
- this.name = 'StoreError'
69
- this.kind = kind
70
- }
71
- }
72
-
73
- /**
74
- * Normalize an arbitrary parsed sidecar document to the current schema.
75
- * v1 to v2: scenes are dropped (the one-click role merged into group
76
- * switches), and origins are migrated into source records (repo = origin
77
- * value, commit snapshot empty — the first update check backfills it).
78
- * v2 to v3: market sources become records ({ repo, ref?, commitSha? })
79
- * instead of bare repo slugs, so a source can pin a release/branch version.
80
- * Returns null when the file claims a newer schema than this plugin
81
- * understands, so the caller starts empty instead of risking data loss.
82
- */
83
- function migrateStore(parsed: unknown): { version: number; disabled: unknown; config?: unknown; tags?: unknown; sources?: unknown; marketSources?: unknown; trash?: unknown; skillStats?: unknown; collectionOrder?: unknown; sourceGroupOrder?: unknown } | null {
84
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
85
- const record = parsed as Record<string, unknown>
86
- const version = typeof record.version === 'number' ? record.version : 0
87
- if (version > STORE_VERSION) return null
88
- const disabled = Array.isArray(record.disabled) ? record.disabled : []
89
- const config = typeof record.config === 'object' && record.config !== null && !Array.isArray(record.config) ? record.config : undefined
90
- const tags = Array.isArray(record.tags) ? record.tags : undefined
91
- const trash = Array.isArray(record.trash) ? record.trash : undefined
92
- // v2 stored bare slugs; v3 stores records. Normalize both shapes here.
93
- const marketSources = Array.isArray(record.marketSources)
94
- ? record.marketSources.map((entry) => typeof entry === 'string'
95
- ? { repo: entry }
96
- : entry)
97
- : undefined
98
-
99
- // v2 stores structured source records; v1 only had a skillName to
100
- // collection string map (origins). Migrate each origin into a SourceRecord
101
- // whose repo is the collection string itself and whose commit snapshot is
102
- // empty.
103
- let sources: unknown
104
- if (Array.isArray(record.sources)) {
105
- sources = record.sources
106
- } else if (version < 2 && typeof record.origins === 'object' && record.origins !== null && !Array.isArray(record.origins)) {
107
- const byOrigin = new Map<string, string[]>()
108
- for (const [name, origin] of Object.entries(record.origins as Record<string, unknown>)) {
109
- if (typeof origin !== 'string' || origin === '') continue
110
- const list = byOrigin.get(origin)
111
- if (list === undefined) byOrigin.set(origin, [name])
112
- else list.push(name)
113
- }
114
- sources = [...byOrigin.entries()].map(([repo, skillNames]) => ({
115
- repo,
116
- root: 'skills',
117
- commitSha: '',
118
- skills: skillNames.sort((a, b) => a.localeCompare(b)),
119
- }))
120
- }
121
- return {
122
- version: STORE_VERSION,
123
- disabled,
124
- ...(config !== undefined ? { config } : {}),
125
- ...(tags !== undefined ? { tags } : {}),
126
- ...(sources !== undefined ? { sources } : {}),
127
- ...(marketSources !== undefined ? { marketSources } : {}),
128
- ...(trash !== undefined ? { trash } : {}),
129
- // v4 (skillStats) is a pure addition — older files simply lack the field,
130
- // and the loader validates its shape, so pass it through untouched.
131
- ...(record.skillStats !== undefined ? { skillStats: record.skillStats } : {}),
132
- ...(Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {}),
133
- ...(Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}),
134
- }
135
- }
136
-
137
- /** Sidecar state owner. */
138
- export class SkillHubStore {
139
- private entries = new Map<string, DisabledSkill>()
140
- private config: Partial<HubConfig> = {}
141
- private tagsById = new Map<string, SkillTag>()
142
- private sourcesByRepo = new Map<string, SourceRecord>()
143
- private marketSources: MarketSourceRecord[] = []
144
- private trashByName = new Map<string, TrashEntry>()
145
- private skillStats: SkillStatsCheckpoint | undefined = undefined
146
- private collectionOrder: string[] = []
147
- private sourceGroupOrder: string[] = []
148
- private loaded = false
149
- /** Serializes persist runs: concurrent mutators must not let an earlier
150
- * snapshot overwrite a later one (rename is atomic, ordering is not). */
151
- private writeChain: Promise<void> = Promise.resolve()
152
-
153
- constructor(private readonly file: string = statePath()) {}
154
-
155
- private async ensureLoaded(): Promise<void> {
156
- if (this.loaded) return
157
- this.loaded = true
158
- try {
159
- const raw = await readFile(this.file, 'utf8')
160
- const parsed: unknown = JSON.parse(raw)
161
- const migrated = migrateStore(parsed)
162
- if (migrated === null) {
163
- console.warn('[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty')
164
- } else {
165
- if (Array.isArray(migrated.disabled)) {
166
- for (const entry of migrated.disabled) {
167
- if (typeof entry?.name === 'string' && typeof entry?.path === 'string') {
168
- this.entries.set(entry.name, entry)
169
- }
170
- }
171
- }
172
- const saved = migrated.config as { enabled?: unknown; announceToAgent?: unknown; dotModelColor?: unknown; dotUserColor?: unknown; showUseCount?: unknown; showUseTime?: unknown; showGroupSummary?: unknown } | undefined
173
- if (typeof saved === 'object' && saved !== null) {
174
- if (typeof saved.enabled === 'boolean') this.config.enabled = saved.enabled
175
- if (typeof saved.announceToAgent === 'boolean') this.config.announceToAgent = saved.announceToAgent
176
- if (typeof saved.dotModelColor === 'string' && saved.dotModelColor !== '') this.config.dotModelColor = saved.dotModelColor
177
- if (typeof saved.dotUserColor === 'string' && saved.dotUserColor !== '') this.config.dotUserColor = saved.dotUserColor
178
- if (typeof saved.showUseCount === 'boolean') this.config.showUseCount = saved.showUseCount
179
- if (typeof saved.showUseTime === 'boolean') this.config.showUseTime = saved.showUseTime
180
- if (typeof saved.showGroupSummary === 'boolean') this.config.showGroupSummary = saved.showGroupSummary
181
- }
182
- if (Array.isArray(migrated.tags)) {
183
- for (const entry of migrated.tags as unknown[]) {
184
- const tag = entry as { id?: unknown; name?: unknown; skillNames?: unknown; default?: unknown } | null
185
- if (tag !== null && typeof tag === 'object' && typeof tag.id === 'string' && typeof tag.name === 'string' && Array.isArray(tag.skillNames)) {
186
- this.tagsById.set(tag.id, {
187
- id: tag.id,
188
- name: tag.name,
189
- skillNames: tag.skillNames.filter((n): n is string => typeof n === 'string'),
190
- ...(tag.default === true ? { default: true } : {}),
191
- })
192
- }
193
- }
194
- }
195
- if (Array.isArray(migrated.sources)) {
196
- for (const entry of migrated.sources as unknown[]) {
197
- const source = entry as { repo?: unknown; ref?: unknown; root?: unknown; commitSha?: unknown; skills?: unknown; manifest?: unknown } | null
198
- if (source !== null && typeof source === 'object' && typeof source.repo === 'string' && source.repo !== '' && Array.isArray(source.skills)) {
199
- const manifest = source.manifest as Record<string, unknown> | undefined
200
- const rawRoot = typeof source.root === 'string' && source.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : 'skills'
201
- this.sourcesByRepo.set(source.repo, {
202
- repo: source.repo,
203
- ...(typeof source.ref === 'string' && source.ref !== '' ? { ref: source.ref } : {}),
204
- root: rawRoot,
205
- commitSha: typeof source.commitSha === 'string' ? source.commitSha : '',
206
- skills: source.skills.filter((n): n is string => typeof n === 'string'),
207
- ...(manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)
208
- ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === 'number')) as Record<string, number> }
209
- : {}),
210
- })
211
- }
212
- }
213
- }
214
- if (Array.isArray(migrated.marketSources)) {
215
- for (const entry of migrated.marketSources as unknown[]) {
216
- const item = entry as { repo?: unknown; ref?: unknown; commitSha?: unknown } | null
217
- if (item !== null && typeof item === 'object' && typeof item.repo === 'string' && item.repo !== '' && !this.marketSources.some((s) => s.repo === item.repo)) {
218
- this.marketSources.push({
219
- repo: item.repo,
220
- ...(typeof item.ref === 'string' && item.ref !== '' ? { ref: item.ref } : {}),
221
- ...(typeof item.commitSha === 'string' && item.commitSha !== '' ? { commitSha: item.commitSha } : {}),
222
- })
223
- }
224
- }
225
- }
226
- if (Array.isArray(migrated.trash)) {
227
- for (const entry of migrated.trash as unknown[]) {
228
- const item = entry as { name?: unknown; path?: unknown; movedAt?: unknown; sourcePath?: unknown } | null
229
- if (item !== null && typeof item === 'object' && typeof item.name === 'string' && typeof item.path === 'string') {
230
- const origin = (item as Record<string, unknown>).origin as { repo?: unknown; root?: unknown; ref?: unknown; commitSha?: unknown } | undefined
231
- const tagIds = (item as Record<string, unknown>).tagIds
232
- this.trashByName.set(item.name, {
233
- name: item.name,
234
- path: item.path,
235
- movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
236
- ...(typeof item.sourcePath === 'string' && item.sourcePath !== '' ? { sourcePath: item.sourcePath } : {}),
237
- ...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' && typeof origin.root === 'string' && origin.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root)
238
- ? {
239
- origin: {
240
- repo: origin.repo,
241
- root: origin.root,
242
- ...(typeof origin.ref === 'string' && origin.ref !== '' ? { ref: origin.ref } : {}),
243
- commitSha: typeof origin.commitSha === 'string' ? origin.commitSha : '',
244
- },
245
- }
246
- : {}),
247
- ...(Array.isArray(tagIds) ? { tagIds: tagIds.filter((id): id is string => typeof id === 'string') } : {}),
248
- })
249
- }
250
- }
251
- }
252
- const savedStats = migrated.skillStats as Partial<SkillStatsCheckpoint> | null | undefined
253
- if (savedStats !== null && typeof savedStats === 'object'
254
- && typeof savedStats.frozenBefore === 'number' && typeof savedStats.lastFullReconcile === 'number'
255
- && typeof savedStats.windowDays === 'number'
256
- && typeof savedStats.frozenSessions === 'object' && savedStats.frozenSessions !== null) {
257
- // Validate entry shapes too: a corrupt bucket degrades to a fresh
258
- // checkpoint (one extra full reconciliation), never to bad counts.
259
- const sessions: SkillStatsCheckpoint['frozenSessions'] = {}
260
- for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
261
- if (entry === null || typeof entry !== 'object' || typeof entry.createdAt !== 'number'
262
- || typeof entry.counts !== 'object' || entry.counts === null) continue
263
- const counts: Record<string, { count: number; lastUsed: number }> = {}
264
- for (const [name, stat] of Object.entries(entry.counts)) {
265
- if (stat !== null && typeof stat === 'object'
266
- && typeof (stat as { count?: unknown }).count === 'number'
267
- && typeof (stat as { lastUsed?: unknown }).lastUsed === 'number') {
268
- counts[name] = { count: (stat as { count: number }).count, lastUsed: (stat as { lastUsed: number }).lastUsed }
269
- }
270
- }
271
- sessions[id] = { createdAt: entry.createdAt, counts }
272
- }
273
- this.skillStats = {
274
- windowDays: savedStats.windowDays,
275
- frozenBefore: savedStats.frozenBefore,
276
- frozenSessions: sessions,
277
- lastFullReconcile: savedStats.lastFullReconcile,
278
- }
279
- }
280
- const rawColOrder = (migrated as Record<string, unknown>).collectionOrder as unknown
281
- if (Array.isArray(rawColOrder)) {
282
- this.collectionOrder = rawColOrder.filter((n): n is string => typeof n === 'string' && n !== '')
283
- }
284
- const rawSrcOrder = (migrated as Record<string, unknown>).sourceGroupOrder as unknown
285
- if (Array.isArray(rawSrcOrder)) {
286
- this.sourceGroupOrder = rawSrcOrder.filter((n): n is string => typeof n === 'string' && n !== '')
287
- }
288
- }
289
- } catch (error) {
290
- // Missing or unreadable state starts empty; never crash the plugin.
291
- const code = (error as NodeJS.ErrnoException).code
292
- if (code !== 'ENOENT') {
293
- console.warn('[dsh-skill-hub] sidecar state unreadable, starting empty:', error instanceof Error ? error.message : error)
294
- }
295
- }
296
- await this.ensureDefaultTag()
297
- }
298
-
299
- /**
300
- * 保证存在默认场景:没有任何 default 标记的 tag 时创建「通用」。
301
- * 新技能创建后自动归入它;用户可改名,但默认场景不可删除。
302
- */
303
- private async ensureDefaultTag(): Promise<void> {
304
- if ([...this.tagsById.values()].some((tag) => tag.default === true)) return
305
- const tag: SkillTag = { id: crypto.randomUUID(), name: DEFAULT_SCENE_NAME, skillNames: [], default: true }
306
- this.tagsById.set(tag.id, tag)
307
- await this.persist()
308
- }
309
-
310
- async listDisabled(): Promise<DisabledSkill[]> {
311
- await this.ensureLoaded()
312
- return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name))
313
- }
314
-
315
- async getDisabled(name: string): Promise<DisabledSkill | undefined> {
316
- await this.ensureLoaded()
317
- return this.entries.get(name)
318
- }
319
-
320
- async addDisabled(entry: DisabledSkill): Promise<void> {
321
- await this.ensureLoaded()
322
- this.entries.set(entry.name, entry)
323
- await this.persist()
324
- }
325
-
326
- async removeDisabled(name: string): Promise<void> {
327
- await this.ensureLoaded()
328
- if (!this.entries.delete(name)) return
329
- await this.persist()
330
- }
331
-
332
- /** The hub's saved runtime configuration (fields absent here mean "not overridden"). */
333
- async getConfig(): Promise<Partial<HubConfig>> {
334
- await this.ensureLoaded()
335
- return { ...this.config }
336
- }
337
-
338
- /**
339
- * Persist a runtime-config patch, merged over the saved values. A field
340
- * whose patch value is undefined is removed from the saved layer, so the
341
- * setting re-inherits its default (the web card's "reset" path).
342
- */
343
- async setConfig(config: Partial<HubConfig>): Promise<void> {
344
- await this.ensureLoaded()
345
- const next: Partial<HubConfig> = { ...this.config }
346
- for (const [key, value] of Object.entries(config) as Array<[keyof HubConfig, boolean | string | undefined]>) {
347
- if (value === undefined) delete next[key]
348
- else (next as unknown as Record<string, unknown>)[key] = value
349
- }
350
- this.config = next
351
- await this.persist()
352
- }
353
-
354
- /** All user-defined tag groups, in creation order. */
355
- async listTags(): Promise<SkillTag[]> {
356
- await this.ensureLoaded()
357
- return [...this.tagsById.values()]
358
- }
359
-
360
- /** One tag by id (undefined when absent). */
361
- async getTag(id: string): Promise<SkillTag | undefined> {
362
- await this.ensureLoaded()
363
- return this.tagsById.get(id)
364
- }
365
-
366
- /**
367
- * Create (no id) or rename (with id) a tag. Returns the saved tag.
368
- * Creating assigns a fresh UUID; renaming keeps members.
369
- */
370
- async saveTag(input: { id?: string; name: string }): Promise<SkillTag> {
371
- await this.ensureLoaded()
372
- const name = input.name.trim()
373
- if (name === '') throw new StoreError('validation', 'tag name must not be empty')
374
- let tag: SkillTag
375
- if (input.id !== undefined) {
376
- const existing = this.tagsById.get(input.id)
377
- if (existing === undefined) throw new StoreError('not-found', 'tag not found: ' + input.id)
378
- tag = { ...existing, name }
379
- } else {
380
- tag = { id: crypto.randomUUID(), name, skillNames: [] }
381
- }
382
- this.tagsById.set(tag.id, tag)
383
- await this.persist()
384
- return tag
385
- }
386
-
387
- /** Delete a tag by id (no-op when absent). The default scene cannot be deleted. */
388
- async deleteTag(id: string): Promise<void> {
389
- await this.ensureLoaded()
390
- const tag = this.tagsById.get(id)
391
- if (tag?.default === true) throw new StoreError('conflict', 'the default scene cannot be deleted')
392
- if (!this.tagsById.delete(id)) return
393
- await this.persist()
394
- }
395
-
396
- /** The default scene (「通用」), guaranteed to exist after ensureLoaded. */
397
- async getDefaultTag(): Promise<SkillTag | undefined> {
398
- await this.ensureLoaded()
399
- return [...this.tagsById.values()].find((tag) => tag.default === true)
400
- }
401
-
402
- /** Append one skill name to a tag (deduplicated; no-op when already a member). */
403
- async addSkillToTag(id: string, name: string): Promise<SkillTag | undefined> {
404
- await this.ensureLoaded()
405
- const existing = this.tagsById.get(id)
406
- if (existing === undefined || name.trim() === '' || existing.skillNames.includes(name)) return existing
407
- const tag: SkillTag = { ...existing, skillNames: [...existing.skillNames, name] }
408
- this.tagsById.set(id, tag)
409
- await this.persist()
410
- return tag
411
- }
412
-
413
- /**
414
- * Replace a tag's member list wholesale (idempotent). Deduplicates and
415
- * drops blank names; unknown skill names are kept (they may arrive later),
416
- * the routes layer filters against the live catalog before persisting.
417
- */
418
- async setTagMembers(id: string, skillNames: readonly string[]): Promise<SkillTag | undefined> {
419
- await this.ensureLoaded()
420
- const existing = this.tagsById.get(id)
421
- if (existing === undefined) return undefined
422
- const names = [...new Set(skillNames.filter((n) => n.trim() !== ''))]
423
- const tag: SkillTag = { ...existing, skillNames: names }
424
- this.tagsById.set(id, tag)
425
- await this.persist()
426
- return tag
427
- }
428
-
429
- /** Remove one skill from every tag group (used when the skill is deleted). */
430
- async removeSkillFromTags(name: string): Promise<void> {
431
- await this.ensureLoaded()
432
- let changed = false
433
- for (const tag of this.tagsById.values()) {
434
- if (!tag.skillNames.includes(name)) continue
435
- tag.skillNames = tag.skillNames.filter((n) => n !== name)
436
- changed = true
437
- }
438
- if (changed) await this.persist()
439
- }
440
-
441
- /** Reorder tag groups by orderedIds (drag-and-drop). */
442
- async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
443
- await this.ensureLoaded()
444
- const currentIds = [...this.tagsById.keys()]
445
- if (orderedIds.length !== currentIds.length) throw new StoreError('validation', 'orderedIds length mismatch')
446
- const seen = new Set<string>()
447
- for (const id of orderedIds) {
448
- if (typeof id !== 'string' || id === '') throw new StoreError('validation', 'invalid tag id')
449
- if (seen.has(id)) throw new StoreError('validation', 'duplicate tag id: ' + id)
450
- if (!this.tagsById.has(id)) throw new StoreError('not-found', 'tag not found: ' + id)
451
- seen.add(id)
452
- }
453
- const newMap = new Map<string, SkillTag>()
454
- for (const id of orderedIds) newMap.set(id, this.tagsById.get(id)!)
455
- this.tagsById = newMap
456
- await this.persist()
457
- return [...this.tagsById.values()]
458
- }
459
-
460
- /** Collection order for 来源分组拖拽 */
461
- async getCollectionOrder(): Promise<string[]> {
462
- await this.ensureLoaded()
463
- return [...this.collectionOrder]
464
- }
465
-
466
- /** Reorder collections by orderedNames (drag-and-drop). */
467
- async reorderCollections(orderedNames: string[]): Promise<string[]> {
468
- await this.ensureLoaded()
469
- const uniq = [...new Set(orderedNames.filter((n): n is string => typeof n === 'string' && n !== ''))]
470
- this.collectionOrder = uniq
471
- await this.persist()
472
- return [...this.collectionOrder]
473
- }
474
-
475
- /** Source top-level group order for 来源分组(project / col:xxx / personal) */
476
- async getSourceGroupOrder(): Promise<string[]> {
477
- await this.ensureLoaded()
478
- return [...this.sourceGroupOrder]
479
- }
480
-
481
- /** Reorder source top-level groups by orderedKeys */
482
- async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
483
- await this.ensureLoaded()
484
- const uniq = [...new Set(orderedKeys.filter((k): k is string => typeof k === 'string' && k !== ''))]
485
- this.sourceGroupOrder = uniq
486
- await this.persist()
487
- return [...this.sourceGroupOrder]
488
- }
489
-
490
- // ------------------------------------------------------------ sources
491
-
492
- /** All source records, sorted by repo. */
493
- async listSources(): Promise<SourceRecord[]> {
494
- await this.ensureLoaded()
495
- return [...this.sourcesByRepo.values()].sort((a, b) => a.repo.localeCompare(b.repo))
496
- }
497
-
498
- /** One source record by repo (undefined when absent). */
499
- async getSource(repo: string): Promise<SourceRecord | undefined> {
500
- await this.ensureLoaded()
501
- return this.sourcesByRepo.get(repo)
502
- }
503
-
504
- /** The source record that tracks a skill name (undefined when untracked). */
505
- async getSourceForSkill(name: string): Promise<SourceRecord | undefined> {
506
- await this.ensureLoaded()
507
- for (const source of this.sourcesByRepo.values()) {
508
- if (source.skills.includes(name)) return source
509
- }
510
- return undefined
511
- }
512
-
513
- /** Remove a source record entirely (no-op when absent). */
514
- async deleteSource(repo: string): Promise<void> {
515
- await this.ensureLoaded()
516
- if (!this.sourcesByRepo.delete(repo)) return
517
- await this.persist()
518
- }
519
-
520
- /**
521
- * Upsert one skill into a source record. When the repo has no record yet a
522
- * new one is created (root + commit snapshot from the caller).
523
- */
524
- async addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
525
- await this.ensureLoaded()
526
- const existing = this.sourcesByRepo.get(repo)
527
- if (existing === undefined) {
528
- this.sourcesByRepo.set(repo, {
529
- repo,
530
- ...(ref !== undefined && ref !== '' ? { ref } : {}),
531
- root,
532
- commitSha,
533
- skills: [skillName],
534
- })
535
- } else {
536
- const skills = existing.skills.includes(skillName) ? existing.skills : [...existing.skills, skillName].sort((a, b) => a.localeCompare(b))
537
- this.sourcesByRepo.set(repo, { ...existing, skills })
538
- }
539
- await this.persist()
540
- }
541
-
542
- /** Replace a source's skill list (used after sync/confirm-delete). */
543
- async setSourceSkills(repo: string, skills: readonly string[]): Promise<SourceRecord | undefined> {
544
- await this.ensureLoaded()
545
- const existing = this.sourcesByRepo.get(repo)
546
- if (existing === undefined) return undefined
547
- const names = [...new Set(skills.filter((n) => n.trim() !== ''))].sort((a, b) => a.localeCompare(b))
548
- if (names.length === 0) {
549
- this.sourcesByRepo.delete(repo)
550
- } else {
551
- this.sourcesByRepo.set(repo, { ...existing, skills: names })
552
- }
553
- await this.persist()
554
- return this.sourcesByRepo.get(repo)
555
- }
556
-
557
- /** Remove one skill from every source record (used when the skill is deleted). */
558
- async removeSkillFromSources(name: string): Promise<void> {
559
- await this.ensureLoaded()
560
- let changed = false
561
- for (const [repo, source] of this.sourcesByRepo) {
562
- if (!source.skills.includes(name)) continue
563
- const skills = source.skills.filter((n) => n !== name)
564
- if (skills.length === 0) this.sourcesByRepo.delete(repo)
565
- else this.sourcesByRepo.set(repo, { ...source, skills })
566
- changed = true
567
- }
568
- if (changed) await this.persist()
569
- }
570
-
571
- /** Update a source's commit snapshot. */
572
- async setSourceCommit(repo: string, commitSha: string): Promise<void> {
573
- await this.ensureLoaded()
574
- const existing = this.sourcesByRepo.get(repo)
575
- if (existing === undefined) return
576
- this.sourcesByRepo.set(repo, { ...existing, commitSha })
577
- await this.persist()
578
- }
579
-
580
- /** Update a source's pinned ref (release tag / branch) when the market syncs. */
581
- async setSourceRef(repo: string, ref: string): Promise<void> {
582
- await this.ensureLoaded()
583
- const existing = this.sourcesByRepo.get(repo)
584
- if (existing === undefined || ref.trim() === '') return
585
- this.sourcesByRepo.set(repo, { ...existing, ref: ref.trim() })
586
- await this.persist()
587
- }
588
-
589
- /**
590
- * Merge per-path manifest entries into a source (incremental imports).
591
- * When `dir` is given, every baseline path under that skill directory is
592
- * dropped first, so files the upstream removed never linger in the
593
- * baseline and skew later update diffs.
594
- */
595
- async mergeSourceManifest(repo: string, manifest: Record<string, number>, dir?: string): Promise<void> {
596
- await this.ensureLoaded()
597
- const existing = this.sourcesByRepo.get(repo)
598
- if (existing === undefined || Object.keys(manifest).length === 0) return
599
- const base: Record<string, number> = { ...(existing.manifest ?? {}) }
600
- if (dir !== undefined && dir !== '') {
601
- const prefix = dir + '/'
602
- for (const path of Object.keys(base)) {
603
- if (path.startsWith(prefix)) delete base[path]
604
- }
605
- }
606
- this.sourcesByRepo.set(repo, { ...existing, manifest: { ...base, ...manifest } })
607
- await this.persist()
608
- }
609
-
610
- /** skillName → collection name for every recorded origin (derived from sources). */
611
- async listOrigins(): Promise<Record<string, string>> {
612
- await this.ensureLoaded()
613
- const origins: Record<string, string> = {}
614
- for (const source of this.sourcesByRepo.values()) {
615
- for (const name of source.skills) origins[name] = source.repo
616
- }
617
- return origins
618
- }
619
-
620
- // ------------------------------------------------------ market sources
621
-
622
- /** The user's market sources, in addition order. */
623
- async listMarketSources(): Promise<MarketSourceRecord[]> {
624
- await this.ensureLoaded()
625
- return [...this.marketSources]
626
- }
627
-
628
- /** One market source by repo (undefined when absent). */
629
- async getMarketSource(repo: string): Promise<MarketSourceRecord | undefined> {
630
- await this.ensureLoaded()
631
- return this.marketSources.find((entry) => entry.repo === repo)
632
- }
633
-
634
- /** Add a repo (deduplicated), optionally with a pinned ref. Returns the fresh list. */
635
- async addMarketSource(repo: string, ref?: string): Promise<MarketSourceRecord[]> {
636
- await this.ensureLoaded()
637
- const existing = this.marketSources.find((entry) => entry.repo === repo)
638
- if (existing === undefined) {
639
- this.marketSources.push(ref !== undefined && ref !== '' ? { repo, ref } : { repo })
640
- } else if (ref !== undefined && ref !== '' && existing.ref !== ref) {
641
- existing.ref = ref
642
- }
643
- await this.persist()
644
- return [...this.marketSources]
645
- }
646
-
647
- /** Remove a repo (no-op when absent). Returns the fresh list. */
648
- async removeMarketSource(repo: string): Promise<MarketSourceRecord[]> {
649
- await this.ensureLoaded()
650
- const index = this.marketSources.findIndex((entry) => entry.repo === repo)
651
- if (index === -1) return [...this.marketSources]
652
- this.marketSources.splice(index, 1)
653
- await this.persist()
654
- return [...this.marketSources]
655
- }
656
-
657
- /** Pin a market source to an explicit ref (branch/tag). Returns the record. */
658
- async setMarketSourceRef(repo: string, ref: string): Promise<MarketSourceRecord | undefined> {
659
- await this.ensureLoaded()
660
- const entry = this.marketSources.find((item) => item.repo === repo)
661
- if (entry === undefined || ref.trim() === '') return entry
662
- entry.ref = ref.trim()
663
- delete entry.commitSha
664
- await this.persist()
665
- return entry
666
- }
667
-
668
- /** Record the commit a market source's pinned ref resolved to (update baseline). */
669
- async setMarketSourceCommit(repo: string, commitSha: string): Promise<void> {
670
- await this.ensureLoaded()
671
- const entry = this.marketSources.find((item) => item.repo === repo)
672
- if (entry === undefined || commitSha === '') return
673
- entry.commitSha = commitSha
674
- await this.persist()
675
- }
676
-
677
- // ---------------------------------------------------------------- trash
678
-
679
- /** All trashed skills, newest first. */
680
- async listTrash(): Promise<TrashEntry[]> {
681
- await this.ensureLoaded()
682
- return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt)
683
- }
684
-
685
- /** One trash entry by skill name (undefined when absent). */
686
- async getTrash(name: string): Promise<TrashEntry | undefined> {
687
- await this.ensureLoaded()
688
- return this.trashByName.get(name)
689
- }
690
-
691
- /** Record a trashed skill. */
692
- async addTrash(entry: TrashEntry): Promise<void> {
693
- await this.ensureLoaded()
694
- this.trashByName.set(entry.name, entry)
695
- await this.persist()
696
- }
697
-
698
- /** Remove a trash record (after restore). */
699
- async removeTrash(name: string): Promise<void> {
700
- await this.ensureLoaded()
701
- if (!this.trashByName.delete(name)) return
702
- await this.persist()
703
- }
704
-
705
- /** The persisted usage-statistics checkpoint (undefined until first saved). */
706
- async getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined> {
707
- await this.ensureLoaded()
708
- return this.skillStats !== undefined
709
- ? { ...this.skillStats, frozenSessions: { ...this.skillStats.frozenSessions } }
710
- : undefined
711
- }
712
-
713
- /** Persist a usage-statistics checkpoint (written at most ~once a day, on full reconciliations). */
714
- async saveSkillStatsState(state: SkillStatsCheckpoint): Promise<void> {
715
- await this.ensureLoaded()
716
- this.skillStats = {
717
- windowDays: state.windowDays,
718
- frozenBefore: state.frozenBefore,
719
- frozenSessions: { ...state.frozenSessions },
720
- lastFullReconcile: state.lastFullReconcile,
721
- }
722
- await this.persist()
723
- }
724
-
725
- private persist(): Promise<void> {
726
- // The payload is built inside the queued step (not here), so every
727
- // concurrent mutation made before a write actually lands is included in
728
- // the final file instead of being rolled back by an older snapshot.
729
- const run = this.writeChain.then(async () => {
730
- const payload: StoreFile = {
731
- version: STORE_VERSION,
732
- disabled: [...this.entries.values()],
733
- config: this.config,
734
- ...(this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {}),
735
- ...(this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {}),
736
- ...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
737
- ...(this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
738
- ...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
739
- ...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
740
- ...(this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}),
741
- }
742
- const tmp = this.file + '.tmp'
743
- await mkdir(dirname(this.file), { recursive: true })
744
- await writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
745
- await rename(tmp, this.file)
746
- })
747
- // Keep the chain alive on failure; callers still observe the rejection.
748
- this.writeChain = run.catch(() => {})
749
- return run
750
- }
751
- }
18
+ export { DEFAULT_SCENE_NAME, STORE_VERSION, dshHome, statePath, type StoreFile } from './store/paths.ts'
19
+ export { StoreError } from './store/errors.ts'
20
+ export { migrateStore, hydrateMigratedState, type MigratedStore, type HydratedState } from './store/migrate.ts'
21
+ export { SkillHubStore } from './store/store.ts'