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.
@@ -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 { DiagnosticEntry, WritableRoot } from '../protocol.ts'
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
- return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined
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
- * show why a skill never appears — a
172
- * missing frontmatter must be visible, not silent). .disabled files belong
173
- * to the hub and are skipped.
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 diagnostics: DiagnosticEntry[] = []
177
- for (const entry of await listSkillEntries(root, home)) {
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
- diagnostics.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
193
+ found.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
184
194
  }
185
- continue
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
- diagnostics.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
191
- continue
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
- diagnostics.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
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
- diagnostics.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
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
- diagnostics.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` })
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
- return diagnostics
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
  }
@@ -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 a skill (directory bundle or flat .md file) into the root's trash.
87
- * The skill stays recoverable: the source is renamed into
88
- * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
89
- * SKILL.md path and moved as a whole directory.
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 trashSkill(sourcePath: string): Promise<{ path: string; source: string }> {
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
- /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
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
- await writeFile(path, repairedText, 'utf8')
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
- const remaining = response.headers.get('x-ratelimit-remaining')
67
- const reset = response.headers.get('x-ratelimit-reset')
68
- if ((response.status === 403 || response.status === 429) && remaining === '0' && reset !== null) {
69
- const at = new Date(Number(reset) * 1000)
70
- return failure('github rate limit reached; retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN')
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
  }