dsh-skill-hub 0.3.19 → 0.3.20
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +10 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +132 -94
- package/lib/types/client/api.d.ts +3 -3
- package/lib/types/protocol/config.d.ts +17 -11
- package/lib/types/protocol/market.d.ts +11 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/repo/github-client.d.ts +11 -4
- package/lib/types/repo.d.ts +1 -1
- package/lib/types/routes/deps.d.ts +6 -1
- package/lib/types/skillfs/scan.d.ts +6 -5
- package/lib/types/skillfs.d.ts +25 -1
- package/package.json +21 -20
- package/src/client/api.ts +4 -3
- package/src/client/panel/hooks/useMarketFlow.ts +4 -2
- package/src/client/settings-card.tsx +2 -1
- package/src/client/settings-form.ts +6 -3
- package/src/index.ts +1 -1
- package/src/protocol/config.ts +27 -24
- package/src/protocol/market.ts +12 -0
- package/src/protocol.ts +1 -0
- package/src/repo/github-client.ts +26 -14
- package/src/repo.test.ts +20 -1
- package/src/repo.ts +1 -1
- package/src/routes/catalog-data.ts +7 -4
- package/src/routes/catalog.ts +4 -9
- package/src/routes/deps.ts +7 -2
- package/src/routes/market.ts +13 -2
- package/src/routes/repo-import.ts +4 -7
- package/src/routes.test.ts +68 -0
- package/src/skillfs/scan.ts +30 -17
- package/src/skillfs.test.ts +64 -3
- package/src/skillfs.ts +39 -8
- package/src/update.ts +6 -6
package/src/skillfs/scan.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { load } from 'js-yaml'
|
|
|
4
4
|
import { parseFrontmatter, repairFrontmatterFileText } from './frontmatter.ts'
|
|
5
5
|
import { rootPath } from './paths.ts'
|
|
6
6
|
import { errorText } from '../error-text.ts'
|
|
7
|
+
import { mapConcurrent } from '../concurrency.ts'
|
|
7
8
|
import { dshHome } from '../store.ts'
|
|
8
|
-
import type
|
|
9
|
+
import { HEX_COLOR_RE, type DiagnosticEntry, type WritableRoot } from '../protocol.ts'
|
|
9
10
|
|
|
10
11
|
/** One discoverable skill file in a scanned root. */
|
|
11
12
|
export interface SkillEntry {
|
|
@@ -130,7 +131,9 @@ export async function readSkillInterface(directory: string): Promise<SkillInterf
|
|
|
130
131
|
const hexColor = (value: unknown): string | undefined => {
|
|
131
132
|
if (typeof value !== 'string') return undefined
|
|
132
133
|
const trimmed = value.trim()
|
|
133
|
-
|
|
134
|
+
// Shared with the settings card and the config route: one definition, so a
|
|
135
|
+
// change to the accepted color format cannot land in only some of them.
|
|
136
|
+
return HEX_COLOR_RE.test(trimmed) ? trimmed : undefined
|
|
134
137
|
}
|
|
135
138
|
const iconPath = (value: unknown): string | undefined => {
|
|
136
139
|
if (typeof value !== 'string' || value.trim() === '') return undefined
|
|
@@ -167,33 +170,40 @@ export async function findProjectRoot(cwd: string): Promise<string> {
|
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
/**
|
|
170
|
-
* Scan one writable root for files the provider ignores, so the GUI can
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
173
|
+
* Scan one writable root for files the provider ignores, so the GUI can show
|
|
174
|
+
* why a skill never appears — a missing frontmatter, a name that disagrees
|
|
175
|
+
* with the discovery path, and the like must be visible, not silent. Files the
|
|
176
|
+
* hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
|
|
177
|
+
* a defect.
|
|
174
178
|
*/
|
|
175
179
|
export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Promise<DiagnosticEntry[]> {
|
|
176
|
-
const
|
|
177
|
-
|
|
180
|
+
const entries = await listSkillEntries(root, home)
|
|
181
|
+
// Each entry is read and parsed independently, and the panel polls /catalog
|
|
182
|
+
// every 5 seconds. A serial read would make the response time grow linearly
|
|
183
|
+
// with the skill count — the same reason the per-skill `stat` collection in
|
|
184
|
+
// routes/catalog-data.ts is already parallel. Bounded, not a bare
|
|
185
|
+
// Promise.all: a root can hold hundreds of entries.
|
|
186
|
+
const perEntry = await mapConcurrent(entries, 8, async (entry): Promise<DiagnosticEntry[]> => {
|
|
187
|
+
const found: DiagnosticEntry[] = []
|
|
178
188
|
let text: string
|
|
179
189
|
try {
|
|
180
190
|
text = await readFile(entry.path, 'utf8')
|
|
181
191
|
} catch (error) {
|
|
182
192
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
183
|
-
|
|
193
|
+
found.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
|
|
184
194
|
}
|
|
185
|
-
|
|
195
|
+
return found
|
|
186
196
|
}
|
|
187
197
|
const parsed = parseFrontmatter(text)
|
|
188
198
|
if ('error' in parsed) {
|
|
189
199
|
const fixable = repairFrontmatterFileText(text) !== null
|
|
190
|
-
|
|
191
|
-
|
|
200
|
+
found.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
|
|
201
|
+
return found
|
|
192
202
|
}
|
|
193
203
|
// File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
|
|
194
204
|
const repaired = repairFrontmatterFileText(text)
|
|
195
205
|
if (repaired !== null) {
|
|
196
|
-
|
|
206
|
+
found.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
|
|
197
207
|
}
|
|
198
208
|
const { value } = parsed
|
|
199
209
|
// The provider registers a skill by its discovery path (directory name or
|
|
@@ -201,13 +211,16 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
|
|
|
201
211
|
// the skill show up under a different identity than its metadata claims.
|
|
202
212
|
const pathName = entry.kind === 'directory' ? basename(entry.directory) : basename(entry.path, '.md')
|
|
203
213
|
if (value.name !== pathName) {
|
|
204
|
-
|
|
214
|
+
found.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
|
|
205
215
|
}
|
|
206
216
|
// Agents decide to auto-activate a skill from its one-line description, so
|
|
207
217
|
// a too-short description leaves the skill hard to discover automatically.
|
|
208
218
|
if (value.description.length < 10) {
|
|
209
|
-
|
|
219
|
+
found.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
|
|
210
220
|
}
|
|
211
|
-
|
|
212
|
-
|
|
221
|
+
return found
|
|
222
|
+
})
|
|
223
|
+
// mapConcurrent preserves index order, so flattening keeps the previous
|
|
224
|
+
// per-entry ordering of the diagnostics.
|
|
225
|
+
return perEntry.flat()
|
|
213
226
|
}
|
package/src/skillfs.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { clearTrash, createSkill, disableSkill, enableSkill, parseFrontmatter, restoreSkill, rootOfPath, scanDiagnostics, trashSkill } from './skillfs.ts'
|
|
5
|
+
import { clearTrash, createSkill, disableSkill, enableSkill, fixDiagnosticFile, parseFrontmatter, restoreSkill, rootOfPath, scanDiagnostics, trashSkill } from './skillfs.ts'
|
|
6
6
|
|
|
7
7
|
describe('parseFrontmatter', () => {
|
|
8
8
|
it('parses a healthy frontmatter', () => {
|
|
@@ -260,6 +260,20 @@ describe('scanDiagnostics', () => {
|
|
|
260
260
|
expect(await scanDiagnostics('user-dsh', join(dir, 'missing'))).toEqual([])
|
|
261
261
|
})
|
|
262
262
|
|
|
263
|
+
// 并发化(读盘 + 解析从串行改为有界并发)不得让输出变得不确定:
|
|
264
|
+
// 诊断顺序必须仍然等于发现顺序(按路径排序)。
|
|
265
|
+
it('keeps diagnostics in discovery order now that the scan is concurrent', async () => {
|
|
266
|
+
await writeFile(join(skills, 'b-bad.md'), '# nope', 'utf8')
|
|
267
|
+
await writeFile(join(skills, 'a-bad.md'), '# nope', 'utf8')
|
|
268
|
+
await writeFile(join(skills, 'c-bad.md'), '# nope', 'utf8')
|
|
269
|
+
const entries = await scanDiagnostics('user-dsh', home)
|
|
270
|
+
expect(entries.map((entry) => entry.path)).toEqual([
|
|
271
|
+
join(skills, 'a-bad.md'),
|
|
272
|
+
join(skills, 'b-bad.md'),
|
|
273
|
+
join(skills, 'c-bad.md'),
|
|
274
|
+
])
|
|
275
|
+
})
|
|
276
|
+
|
|
263
277
|
it('flags a frontmatter name that diverges from the discovery path', async () => {
|
|
264
278
|
await mkdir(join(skills, 'folder-name'))
|
|
265
279
|
await writeFile(join(skills, 'folder-name', 'SKILL.md'), '---\nname: other-name\ndescription: A decent longer description\n---', 'utf8')
|
|
@@ -277,6 +291,53 @@ describe('scanDiagnostics', () => {
|
|
|
277
291
|
})
|
|
278
292
|
})
|
|
279
293
|
|
|
294
|
+
describe('fixDiagnosticFile', () => {
|
|
295
|
+
let dir: string
|
|
296
|
+
let home: string
|
|
297
|
+
let skills: string
|
|
298
|
+
|
|
299
|
+
beforeEach(async () => {
|
|
300
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-fixdiag-'))
|
|
301
|
+
home = join(dir, 'home')
|
|
302
|
+
skills = join(home, 'skills')
|
|
303
|
+
await mkdir(skills, { recursive: true })
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
afterEach(async () => {
|
|
307
|
+
await rm(dir, { recursive: true, force: true })
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('persists the repair atomically and leaves no temp file behind', async () => {
|
|
311
|
+
const path = join(skills, 'needs-fix', 'SKILL.md')
|
|
312
|
+
await mkdir(dirname(path), { recursive: true })
|
|
313
|
+
// Unquoted colon in the description: parseable only after an in-memory
|
|
314
|
+
// repair, which is exactly what the "Fix" action persists.
|
|
315
|
+
await writeFile(path, '---\nname: needs-fix\ndescription: Build for AWS: ECS\n---\nbody', 'utf8')
|
|
316
|
+
const before = await scanDiagnostics('user-dsh', home)
|
|
317
|
+
expect(before).toHaveLength(1)
|
|
318
|
+
expect(before[0]).toMatchObject({ path, fixable: true })
|
|
319
|
+
|
|
320
|
+
expect(await fixDiagnosticFile(path, home)).toBe(path)
|
|
321
|
+
|
|
322
|
+
const parsed = parseFrontmatter(await readFile(path, 'utf8'))
|
|
323
|
+
if ('error' in parsed) throw new Error('repaired file should parse: ' + parsed.error)
|
|
324
|
+
expect(parsed.value.description).toBe('Build for AWS: ECS')
|
|
325
|
+
expect(await scanDiagnostics('user-dsh', home)).toEqual([])
|
|
326
|
+
// Atomic write = sibling temp file + rename, so no .tmp may survive.
|
|
327
|
+
expect(await readdir(dirname(path))).toEqual(['SKILL.md'])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('refuses paths outside the writable roots and files that need no repair', async () => {
|
|
331
|
+
await expect(fixDiagnosticFile(join(dir, 'outside', 'SKILL.md'), home)).rejects.toThrow(/not a hub writable skill path/)
|
|
332
|
+
|
|
333
|
+
const healthy = join(skills, 'fine', 'SKILL.md')
|
|
334
|
+
await mkdir(dirname(healthy), { recursive: true })
|
|
335
|
+
await writeFile(healthy, '---\nname: fine\ndescription: A perfectly fine description\n---\nbody', 'utf8')
|
|
336
|
+
await expect(fixDiagnosticFile(healthy, home)).rejects.toThrow(/not auto-fixable/)
|
|
337
|
+
expect(await readdir(dirname(healthy))).toEqual(['SKILL.md'])
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
280
341
|
describe('rootOfPath', () => {
|
|
281
342
|
it('recognizes the writable roots', async () => {
|
|
282
343
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-root-'))
|
package/src/skillfs.ts
CHANGED
|
@@ -83,14 +83,18 @@ export async function enableSkill(disabledPath: string): Promise<string> {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Move
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* Move one already-resolved skill (a bundle directory or a flat .md file) into
|
|
87
|
+
* its root's trash as `<root>/.trash/<name>-<timestamp>`.
|
|
88
|
+
*
|
|
89
|
+
* The layout is the restore contract, not an implementation detail:
|
|
90
|
+
* `restoreSkill` renames the entry back and only proves the parent is `.trash`,
|
|
91
|
+
* and `clearTrash` deletes in place. Every producer of a trash entry must go
|
|
92
|
+
* through here — skill delete, the hub-disabled whole-group delete, and repo
|
|
93
|
+
* import's placeholder replacement all move skills for the same recovery flow.
|
|
94
|
+
*
|
|
90
95
|
* @returns the trashed path plus the original path that was moved.
|
|
91
96
|
*/
|
|
92
|
-
export async function
|
|
93
|
-
const source = basename(sourcePath) === 'SKILL.md' ? dirname(sourcePath) : sourcePath
|
|
97
|
+
export async function moveIntoTrash(source: string): Promise<{ path: string; source: string }> {
|
|
94
98
|
const trashDir = join(dirname(source), '.trash')
|
|
95
99
|
await mkdir(trashDir, { recursive: true })
|
|
96
100
|
const target = join(trashDir, basename(source) + '-' + Date.now())
|
|
@@ -98,6 +102,17 @@ export async function trashSkill(sourcePath: string): Promise<{ path: string; so
|
|
|
98
102
|
return { path: target, source }
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Move a skill (directory bundle or flat .md file) into the root's trash.
|
|
107
|
+
* The skill stays recoverable: the source is renamed into
|
|
108
|
+
* <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
|
|
109
|
+
* SKILL.md path and moved as a whole directory.
|
|
110
|
+
* @returns the trashed path plus the original path that was moved.
|
|
111
|
+
*/
|
|
112
|
+
export async function trashSkill(sourcePath: string): Promise<{ path: string; source: string }> {
|
|
113
|
+
return moveIntoTrash(basename(sourcePath) === 'SKILL.md' ? dirname(sourcePath) : sourcePath)
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
/** Restore a trashed skill (directory or flat file) to its original location. */
|
|
102
117
|
export async function restoreSkill(entry: TrashEntry, home = dshHome()): Promise<string> {
|
|
103
118
|
if (basename(dirname(entry.path)) !== '.trash') {
|
|
@@ -127,7 +142,15 @@ function skillFileKind(path: string): 'directory' | 'flat' | undefined {
|
|
|
127
142
|
return undefined
|
|
128
143
|
}
|
|
129
144
|
|
|
130
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Repair one file on disk when its frontmatter is auto-fixable. Returns the new text.
|
|
147
|
+
*
|
|
148
|
+
* The write is atomic (sibling temp file + rename), matching the sidecar's
|
|
149
|
+
* persist path: this rewrites a real skill file in place, and a crash midway
|
|
150
|
+
* through a plain write would leave a truncated SKILL.md — exactly the failure
|
|
151
|
+
* the store layer already guards against. The `.tmp` sibling is never picked
|
|
152
|
+
* up by discovery (only `SKILL.md` and `*.md` are scanned).
|
|
153
|
+
*/
|
|
131
154
|
export async function fixDiagnosticFile(path: string, home = dshHome()): Promise<string> {
|
|
132
155
|
const root = rootOfPath(path, home)
|
|
133
156
|
if (root === undefined) throw new TypeError('not a hub writable skill path: ' + path)
|
|
@@ -137,6 +160,14 @@ export async function fixDiagnosticFile(path: string, home = dshHome()): Promise
|
|
|
137
160
|
// Validate the repaired text parses as a legal skill (prevents writing a still-broken file).
|
|
138
161
|
const parsed = parseFrontmatter(repairedText)
|
|
139
162
|
if ('error' in parsed) throw new TypeError('repaired frontmatter still invalid: ' + parsed.error)
|
|
140
|
-
|
|
163
|
+
const tmp = path + '.tmp'
|
|
164
|
+
await writeFile(tmp, repairedText, 'utf8')
|
|
165
|
+
try {
|
|
166
|
+
await rename(tmp, path)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// Never leave a stray temp file behind when the rename is what failed.
|
|
169
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
141
172
|
return path
|
|
142
173
|
}
|
package/src/update.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { errorText } from './error-text.ts'
|
|
12
|
-
import { apiHeaders } from './repo.ts'
|
|
12
|
+
import { apiHeaders, rateLimitResetAt } from './repo.ts'
|
|
13
13
|
import type { UpdateCheckResponse } from './protocol.ts'
|
|
14
14
|
|
|
15
15
|
/** Repository checked for releases. Keep in sync with package.json. */
|
|
@@ -63,11 +63,11 @@ export async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl: typeof f
|
|
|
63
63
|
|
|
64
64
|
if (!response.ok) {
|
|
65
65
|
// Rate-limit exhaustion gets a friendlier message with the reset time.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return failure('github rate limit reached; retry after ' +
|
|
66
|
+
// The header parsing lives in github-client so the repo layer and this
|
|
67
|
+
// check can never disagree on what counts as exhaustion.
|
|
68
|
+
const resetAt = rateLimitResetAt(response)
|
|
69
|
+
if (resetAt !== undefined) {
|
|
70
|
+
return failure('github rate limit reached; retry after ' + resetAt.toLocaleTimeString() + ' or set GITHUB_TOKEN')
|
|
71
71
|
}
|
|
72
72
|
return failure('GitHub HTTP ' + response.status)
|
|
73
73
|
}
|