dsh-skill-hub 0.3.6 → 0.3.7

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 (38) hide show
  1. package/lib/client.js +890 -356
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +401 -47
  4. package/lib/types/client/SkillHubSettingsCard.d.ts +0 -2
  5. package/lib/types/client/api.d.ts +5 -1
  6. package/lib/types/client/helpers.d.ts +10 -0
  7. package/lib/types/client/locales.d.ts +30 -0
  8. package/lib/types/client/panel/DisabledRow.d.ts +2 -0
  9. package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
  10. package/lib/types/client/panel/dialogs.d.ts +19 -0
  11. package/lib/types/client/panel/useSkillHub.d.ts +12 -1
  12. package/lib/types/client/settings-card.d.ts +0 -17
  13. package/lib/types/client/settings-form.d.ts +0 -1
  14. package/lib/types/protocol.d.ts +40 -17
  15. package/lib/types/repo.d.ts +4 -6
  16. package/lib/types/skillfs.d.ts +13 -0
  17. package/package.json +29 -28
  18. package/src/client/SkillHubSettingsCard.tsx +0 -2
  19. package/src/client/api.ts +18 -0
  20. package/src/client/helpers.ts +40 -0
  21. package/src/client/locales.ts +60 -0
  22. package/src/client/panel/DisabledRow.tsx +22 -7
  23. package/src/client/panel/MarketView.tsx +8 -1
  24. package/src/client/panel/ScenesView.tsx +1 -1
  25. package/src/client/panel/SkillDetailView.tsx +35 -11
  26. package/src/client/panel/SkillHubPanel.tsx +42 -4
  27. package/src/client/panel/SkillRow.tsx +5 -2
  28. package/src/client/panel/SourcesView.tsx +2 -17
  29. package/src/client/panel/dialogs.tsx +61 -0
  30. package/src/client/panel/panel.module.css +1 -8
  31. package/src/client/panel/useSkillHub.ts +97 -12
  32. package/src/client/settings-card.tsx +0 -42
  33. package/src/client/settings-form.ts +1 -5
  34. package/src/protocol.ts +42 -8
  35. package/src/repo.ts +71 -29
  36. package/src/routes.test.ts +22 -0
  37. package/src/routes.ts +181 -10
  38. package/src/skillfs.ts +188 -3
package/src/routes.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  getLatestReleaseTag,
30
30
  isAbortError,
31
31
  listRepoBranches,
32
+ listRepoReleases,
32
33
  loadRepoTree,
33
34
  loadRepoTreeAt,
34
35
  normalizeRepoInput,
@@ -60,6 +61,7 @@ import {
60
61
  type MarketSourceRequest,
61
62
  type MarketSourceResponse,
62
63
  type MarketSourcesResponse,
64
+ type MarketSourceVersionsResponse,
63
65
  type MarketSyncResponse,
64
66
  type SourceCheckRequest,
65
67
  type SourceCheckResponse,
@@ -88,6 +90,8 @@ import {
88
90
  type SourceGroupReorderResponse,
89
91
  type TagSaveRequest,
90
92
  type TagSaveResponse,
93
+ type DiagnosticFixRequest,
94
+ type DiagnosticFixResponse,
91
95
  type ToggleBatchRequest,
92
96
  type ToggleBatchResponse,
93
97
  type ToggleRequest,
@@ -97,7 +101,7 @@ import {
97
101
  isProjectSource,
98
102
  resolveHubConfig,
99
103
  } from './protocol.ts'
100
- import { clearTrash, createSkill, disableSkill, enableSkill, restoreSkill, rootOfPath, rootPath, scanDiagnostics, trashSkill } from './skillfs.ts'
104
+ import { clearTrash, createSkill, disableSkill, enableSkill, fixDiagnosticFile, parseFrontmatter, readSkillInterface, restoreSkill, rootOfPath, rootPath, scanDiagnostics, trashSkill } from './skillfs.ts'
101
105
  import { checkLatestRelease, CURRENT_VERSION } from './update.ts'
102
106
  import { dshHome, StoreError, type SkillHubStore } from './store.ts'
103
107
  import type { SkillStatsReader } from './stats.ts'
@@ -365,20 +369,49 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
365
369
  workspaces = await workspaceEntries(home)
366
370
  if (workspaces.length === 0) workspaces = [{ path: '', title: '' }]
367
371
  }
368
- const byName = new Map<string, { skill: SkillSummary; workspace?: string; workspaceTitle?: string }>()
372
+ // Project skills with same name but different workspace are distinct entries (grouped by workspace in SourcesView), so key includes workspace for project sources.
373
+ const byKey = new Map<string, { skill: SkillSummary; workspace?: string; workspaceTitle?: string }>()
374
+ // Distinct identities per logical key: the same skill reappearing across
375
+ // workspace snapshots (same source+provider) is one skill, not a duplicate.
376
+ // Only different source/provider identities sharing a name are ambiguous.
377
+ const identitiesByKey = new Map<string, Set<string>>()
369
378
  let complete = true
370
379
  for (const ws of workspaces) {
371
380
  const lookup = ws.path !== '' ? { cwd: ws.path } : undefined
372
381
  const snapshot = await deps.skills.snapshot(lookup)
373
382
  if (!snapshot.complete) complete = false
374
383
  for (const skill of snapshot.skills) {
375
- if (byName.has(skill.name)) continue
376
- byName.set(skill.name, {
384
+ const logicalKey = isProjectSource(skill.source) && ws.path !== '' ? `${skill.name}\0${ws.path}\0${skill.source}` : skill.name
385
+ let identities = identitiesByKey.get(logicalKey)
386
+ if (identities === undefined) {
387
+ identities = new Set()
388
+ identitiesByKey.set(logicalKey, identities)
389
+ }
390
+ identities.add(skill.source + '\0' + skill.provider)
391
+ if (byKey.has(logicalKey)) continue
392
+ byKey.set(logicalKey, {
377
393
  skill,
378
394
  ...(isProjectSource(skill.source) && ws.path !== '' ? { workspace: ws.path, workspaceTitle: ws.title } : {}),
379
395
  })
380
396
  }
381
397
  }
398
+ const disabled = await deps.store.listDisabled()
399
+ const byName = byKey
400
+ // A hub-disabled record whose name is also enabled elsewhere hides one
401
+ // identity behind the toggle; flag it as well.
402
+ for (const d of disabled) {
403
+ let identities = identitiesByKey.get(d.name)
404
+ if (identities === undefined) {
405
+ identities = new Set()
406
+ identitiesByKey.set(d.name, identities)
407
+ }
408
+ identities.add('hub-disabled\0' + d.root)
409
+ }
410
+ const duplicateNames = [...identitiesByKey.entries()]
411
+ .filter(([, identities]) => identities.size > 1)
412
+ .map(([key]) => key.split('\0')[0])
413
+ .filter((name, idx, arr) => arr.indexOf(name) === idx)
414
+ .sort((a, b) => a.localeCompare(b))
382
415
  // 添加/更新时间 = 用户级技能文件的创建/修改时间(排序与详情展示用)。
383
416
  // snapshot 只给 SkillSummary(无 path),所以按可写根推断路径;非用户级
384
417
  // 来源没有稳定路径,省略字段,客户端排序会把它放到末尾。
@@ -387,7 +420,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
387
420
  const timesByName = new Map<string, { addedAt: number; updatedAt: number }>()
388
421
  await Promise.all([...byName.values()].map(async ({ skill }) => {
389
422
  if (!isWritableSource(skill.source)) return
390
- const base = rootPath(skill.source, home)
423
+ const base = rootPath(skill.source as WritableRoot, home)
391
424
  for (const candidate of [join(base, skill.name, 'SKILL.md'), join(base, skill.name), join(base, skill.name + '.md')]) {
392
425
  try {
393
426
  const times = await stat(candidate)
@@ -398,6 +431,29 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
398
431
  }
399
432
  }
400
433
  }))
434
+ // UI metadata from agents/openai.yaml (codex SkillInterface) — best-effort, no error if missing.
435
+ const interfaceByName = new Map<string, { displayName?: string; shortDescription?: string; brandColor?: string; iconSmall?: string; iconLarge?: string; defaultPrompt?: string }>()
436
+ await Promise.all([...byName.values()].map(async ({ skill, workspace }) => {
437
+ const candidates: string[] = []
438
+ if (isWritableSource(skill.source as WritableRoot)) {
439
+ candidates.push(join(rootPath(skill.source as WritableRoot, home), skill.name))
440
+ } else if (isProjectSource(skill.source) && workspace !== undefined) {
441
+ candidates.push(join(workspace, skill.source === 'project-dsh' ? '.dsh/skills' : '.agents/skills', skill.name))
442
+ } else if (isProjectSource(skill.source)) {
443
+ for (const ws of workspaces) if (ws.path !== '') candidates.push(join(ws.path, skill.source === 'project-dsh' ? '.dsh/skills' : '.agents/skills', skill.name))
444
+ }
445
+ for (const dir of candidates) {
446
+ try {
447
+ const iface = await readSkillInterface(dir)
448
+ if (iface !== undefined) {
449
+ interfaceByName.set(skill.name, iface)
450
+ return
451
+ }
452
+ } catch {
453
+ // ignore
454
+ }
455
+ }
456
+ }))
401
457
  const skills: CatalogSkill[] = [...byName.values()].map(({ skill, workspace, workspaceTitle }) => {
402
458
  const row: CatalogSkill = {
403
459
  name: skill.name,
@@ -417,14 +473,30 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
417
473
  row.addedAt = times.addedAt
418
474
  row.updatedAt = times.updatedAt
419
475
  }
476
+ const iface = interfaceByName.get(skill.name)
477
+ if (iface !== undefined) {
478
+ if (iface.displayName !== undefined) row.displayName = iface.displayName
479
+ if (iface.shortDescription !== undefined) row.shortDescription = iface.shortDescription
480
+ if (iface.brandColor !== undefined) row.brandColor = iface.brandColor
481
+ if (iface.iconSmall !== undefined) row.iconSmall = iface.iconSmall
482
+ if (iface.iconLarge !== undefined) row.iconLarge = iface.iconLarge
483
+ if (iface.defaultPrompt !== undefined) row.defaultPrompt = iface.defaultPrompt
484
+ }
420
485
  return row
421
486
  })
422
- const disabled = await deps.store.listDisabled()
423
487
  const diagnostics = [
424
488
  ...(await scanDiagnostics('user-dsh', home)),
425
489
  ...(await scanDiagnostics('user-agents', home)),
426
490
  ]
427
- return { ok: true, pluginVersion: CURRENT_VERSION, complete, skills, disabled, diagnostics }
491
+ return {
492
+ ok: true,
493
+ pluginVersion: CURRENT_VERSION,
494
+ complete,
495
+ skills,
496
+ disabled,
497
+ diagnostics,
498
+ ...(duplicateNames.length > 0 ? { duplicateNames } : {}),
499
+ }
428
500
  }
429
501
 
430
502
  /** Map a loaded definition onto the wire shape. */
@@ -590,7 +662,41 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
590
662
  }
591
663
  if (skill === undefined) skill = await deps.skills.get(name)
592
664
  }
593
- if (skill === undefined) { writeError(res, 404, 'skill not found: ' + name); return }
665
+ if (skill === undefined) {
666
+ // Hub-disabled skills live outside registry discovery (renamed to .disabled);
667
+ // serve their detail straight from the sidecar record + file.
668
+ const record = await deps.store.getDisabled(name)
669
+ if (record !== undefined) {
670
+ try {
671
+ const text = await readFile(record.path, 'utf8')
672
+ const parsed = parseFrontmatter(text)
673
+ if (!('error' in parsed)) {
674
+ const disabledDetail: SkillDetail = {
675
+ name: record.name,
676
+ description: parsed.value.description,
677
+ ...(parsed.value.whenToUse !== undefined ? { whenToUse: parsed.value.whenToUse } : {}),
678
+ invocation: { ...parsed.value.invocation },
679
+ provider: 'skill-hub (disabled)',
680
+ path: record.path,
681
+ content: parsed.value.content,
682
+ }
683
+ try {
684
+ const times = await stat(record.path)
685
+ disabledDetail.addedAt = times.birthtimeMs
686
+ disabledDetail.updatedAt = times.mtimeMs
687
+ } catch {
688
+ // ignore
689
+ }
690
+ writeJson(res, 200, { ok: true, skill: disabledDetail } satisfies SkillDetailResponse)
691
+ return
692
+ }
693
+ } catch {
694
+ // fall through to 404 below
695
+ }
696
+ }
697
+ writeError(res, 404, 'skill not found: ' + name)
698
+ return
699
+ }
594
700
  const detail = toDetail(skill)
595
701
  if (skill.path !== undefined) {
596
702
  try {
@@ -600,6 +706,24 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
600
706
  } catch {
601
707
  // 文件不可读时省略时间字段,详情页不显示这两行。
602
708
  }
709
+ // UI metadata from agents/openai.yaml beside the skill directory (codex).
710
+ try {
711
+ const rb = skill.resourceBase as { kind?: string; path?: string } | undefined
712
+ const dir = rb?.kind === 'directory' && typeof rb.path === 'string' ? rb.path : (skill.path.endsWith('SKILL.md') ? dirname(skill.path) : undefined)
713
+ if (dir !== undefined) {
714
+ const iface = await readSkillInterface(dir)
715
+ if (iface !== undefined) {
716
+ if (iface.displayName !== undefined) detail.displayName = iface.displayName
717
+ if (iface.shortDescription !== undefined) detail.shortDescription = iface.shortDescription
718
+ if (iface.brandColor !== undefined) detail.brandColor = iface.brandColor
719
+ if (iface.iconSmall !== undefined) detail.iconSmall = iface.iconSmall
720
+ if (iface.iconLarge !== undefined) detail.iconLarge = iface.iconLarge
721
+ if (iface.defaultPrompt !== undefined) detail.defaultPrompt = iface.defaultPrompt
722
+ }
723
+ }
724
+ } catch {
725
+ // best-effort
726
+ }
603
727
  }
604
728
  writeJson(res, 200, { ok: true, skill: detail } satisfies SkillDetailResponse)
605
729
  },
@@ -882,6 +1006,28 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
882
1006
  writeJson(res, 200, { ok: true, repos: await deps.store.listMarketSources() } satisfies MarketSourceResponse)
883
1007
  },
884
1008
  }),
1009
+ // -------------------------------------------- market/source/versions
1010
+ // Version picker data: release tags + branch names for one market source.
1011
+ route({
1012
+ path: SKILL_HUB_API.marketSourceVersions,
1013
+ methods: ['GET'],
1014
+ handler: async ({ res, url }) => {
1015
+ const input = queryParam(url, 'repo') ?? ''
1016
+ const parsed = normalizeRepoInput(input)
1017
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
1018
+ const repo = repoSlug(parsed)
1019
+ const source = await deps.store.getMarketSource(repo)
1020
+ if (source === undefined) { writeError(res, 404, 'market source not found: ' + repo); return }
1021
+ const [releases, branches] = await Promise.all([listRepoReleases(repo), listRepoBranches(repo)])
1022
+ writeJson(res, 200, {
1023
+ ok: true,
1024
+ repo,
1025
+ ...(source.ref !== undefined && source.ref !== '' ? { current: source.ref } : {}),
1026
+ releases,
1027
+ branches,
1028
+ } satisfies MarketSourceVersionsResponse)
1029
+ },
1030
+ }),
885
1031
  // ------------------------------------------------------- market/check
886
1032
  // Update check over every market source: compares the pinned ref's
887
1033
  // commit against the recorded baseline and surfaces newer releases.
@@ -986,6 +1132,12 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
986
1132
  const releaseTag = await getLatestReleaseTag(repo)
987
1133
  if (releaseTag !== undefined) {
988
1134
  ref = releaseTag
1135
+ // Auto-pin an existing but unpinned market source to the resolved
1136
+ // release, so the row shows the tracked version from the first scan.
1137
+ const existing = await deps.store.getMarketSource(repo)
1138
+ if (existing !== undefined && (existing.ref === undefined || existing.ref === '')) {
1139
+ await deps.store.setMarketSourceRef(repo, releaseTag)
1140
+ }
989
1141
  } else {
990
1142
  const branches = await listRepoBranches(repo)
991
1143
  if (branches.length === 0) { writeError(res, 404, 'repo has no branches to scan'); return }
@@ -993,10 +1145,10 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
993
1145
  return
994
1146
  }
995
1147
  }
996
- const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
1148
+ const { ref: resolvedRef, tree, truncated } = await loadRepoTree(repo, ref)
997
1149
  const existing = await knownSkillNames(deps)
998
1150
  const entries = discoverRepoEntries(tree, repo, existing)
999
- writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries } satisfies RepoDiscoverResponse)
1151
+ writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries, ...(truncated ? { truncated: true } : {}) } satisfies RepoDiscoverResponse)
1000
1152
  },
1001
1153
  }),
1002
1154
  // ----------------------------------------------------------- repo/import B方案 Job
@@ -1018,6 +1170,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
1018
1170
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
1019
1171
  const existing = await knownSkillNames(deps)
1020
1172
  const entries = discoverRepoEntries(tree, repo, existing)
1173
+ // truncated tree is still usable for the selected paths the user already picked from the prior discover response
1021
1174
  const byPath = new Map(entries.map((entry) => [entry.path, entry]))
1022
1175
  const selected = paths.map((path) => byPath.get(path)).filter((entry): entry is NonNullable<typeof entry> => entry !== undefined)
1023
1176
  if (selected.length === 0) { writeError(res, 400, 'no matching skills for selected paths'); return }
@@ -1546,5 +1699,23 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
1546
1699
  writeJson(res, 200, { ok: true, deleted, failed } satisfies SourceTrashClearResponse)
1547
1700
  },
1548
1701
  }),
1702
+ // -------------------------------------------------------- diagnostic fix
1703
+ route({
1704
+ path: SKILL_HUB_API.diagnosticFix,
1705
+ methods: ['POST'],
1706
+ jsonBody: true,
1707
+ handler: async ({ res, body }) => {
1708
+ const request = body as unknown as DiagnosticFixRequest
1709
+ const rawPath = typeof request.path === 'string' ? request.path.trim() : ''
1710
+ if (rawPath === '') { writeError(res, 400, 'path is required'); return }
1711
+ try {
1712
+ await fixDiagnosticFile(rawPath, homeOf(deps))
1713
+ deps.invalidate?.()
1714
+ writeJson(res, 200, { ok: true, path: rawPath } satisfies DiagnosticFixResponse)
1715
+ } catch (error) {
1716
+ writeRouteError(res, error)
1717
+ }
1718
+ },
1719
+ }),
1549
1720
  ]
1550
1721
  }
package/src/skillfs.ts CHANGED
@@ -171,10 +171,21 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
171
171
  const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
172
172
  if (match === null) return { error: 'missing YAML frontmatter (--- block)' }
173
173
  let data: unknown
174
+ let rawFrontmatter = match[1]
174
175
  try {
175
- data = load(match[1])
176
+ data = load(rawFrontmatter)
176
177
  } catch (error) {
177
- return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
178
+ const repaired = repairFrontmatterScalarFields(rawFrontmatter)
179
+ if (repaired !== null) {
180
+ try {
181
+ data = load(repaired)
182
+ rawFrontmatter = repaired
183
+ } catch {
184
+ return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
185
+ }
186
+ } else {
187
+ return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
188
+ }
178
189
  }
179
190
  if (typeof data !== 'object' || data === null || Array.isArray(data)) {
180
191
  return { error: 'frontmatter must be a YAML mapping' }
@@ -200,6 +211,125 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
200
211
  return { value: { name, description, ...(whenToUse !== undefined ? { whenToUse } : {}), invocation, content: (match[2] ?? '').trim() } }
201
212
  }
202
213
 
214
+ /**
215
+ * Repair bare scalar fields that contain an unquoted colon (e.g. `description: Build for AWS: ECS`)
216
+ * or an invalid flow-like scalar (`@`, `` ` ``, `[`, `{`). Mirrors codex `repair_frontmatter_scalar_fields`:
217
+ * line-oriented, only touches lines where quoting makes the YAML valid, and preserves block scalars.
218
+ * Returns the repaired frontmatter string when at least one line was quoted, otherwise null.
219
+ */
220
+ function repairFrontmatterScalarFields(frontmatter: string): string | null {
221
+ let changed = false
222
+ let blockScalarIndent: number | null = null
223
+ const repairedLines: string[] = []
224
+ for (const line of frontmatter.split('\n')) {
225
+ const indent = line.search(/[^ ]/)
226
+ const effectiveIndent = indent === -1 ? line.length : indent
227
+ if (blockScalarIndent !== null) {
228
+ if (line.trim() === '' || effectiveIndent > blockScalarIndent) {
229
+ repairedLines.push(line)
230
+ continue
231
+ }
232
+ blockScalarIndent = null
233
+ }
234
+ const colonIndex = line.indexOf(':')
235
+ if (colonIndex === -1) {
236
+ repairedLines.push(line)
237
+ continue
238
+ }
239
+ const key = line.slice(0, colonIndex)
240
+ const value = line.slice(colonIndex + 1)
241
+ if (key.trim() === '' || value.length === 0 || !/^\s/.test(value)) {
242
+ repairedLines.push(line)
243
+ continue
244
+ }
245
+ const trimmedStart = value.trimStart()
246
+ const leadingWhitespace = value.slice(0, value.length - trimmedStart.length)
247
+ let scalar = trimmedStart
248
+ let comment = ''
249
+ // Split trailing `# comment` only when `#` is preceded by whitespace and
250
+ // followed by whitespace/end (YAML comment rule). This preserves `#1` as content.
251
+ for (let idx = 0; idx < trimmedStart.length; idx += 1) {
252
+ if (trimmedStart[idx] === '#') {
253
+ const prev = idx === 0 ? ' ' : trimmedStart[idx - 1]
254
+ const next = idx + 1 < trimmedStart.length ? trimmedStart[idx + 1] : ' '
255
+ if (/\s/.test(prev) && /\s/.test(next)) {
256
+ const commentStart = trimmedStart.slice(0, idx).trimEnd().length
257
+ scalar = trimmedStart.slice(0, commentStart)
258
+ comment = trimmedStart.slice(commentStart)
259
+ break
260
+ }
261
+ }
262
+ }
263
+ scalar = scalar.trimEnd()
264
+ if (scalar === '') {
265
+ repairedLines.push(line)
266
+ continue
267
+ }
268
+ const firstChar = scalar[0]
269
+ if (firstChar === '|' || firstChar === '>') {
270
+ blockScalarIndent = effectiveIndent
271
+ repairedLines.push(line)
272
+ continue
273
+ }
274
+ if (firstChar === "'" || firstChar === '"') {
275
+ repairedLines.push(line)
276
+ continue
277
+ }
278
+ let hasColonSeparator = false
279
+ for (let i = 0; i < scalar.length - 1; i += 1) {
280
+ if (scalar[i] === ':' && /\s/.test(scalar[i + 1])) {
281
+ hasColonSeparator = true
282
+ break
283
+ }
284
+ }
285
+ let invalidFlowLike = false
286
+ if ((firstChar === '[' || firstChar === '{' || firstChar === '@' || firstChar === '`')) {
287
+ try {
288
+ load(scalar)
289
+ } catch {
290
+ invalidFlowLike = true
291
+ }
292
+ }
293
+ if (!hasColonSeparator && !invalidFlowLike) {
294
+ repairedLines.push(line)
295
+ continue
296
+ }
297
+ const quotedScalar = "'" + scalar.replace(/'/g, "''") + "'"
298
+ repairedLines.push(key + ':' + leadingWhitespace + quotedScalar + comment)
299
+ changed = true
300
+ }
301
+ return changed ? repairedLines.join('\n') : null
302
+ }
303
+
304
+ /** Try to repair a SKILL.md's frontmatter in place (unquoted colon etc.). Returns the new file text or null when not fixable. */
305
+ export function repairFrontmatterFileText(text: string): string | null {
306
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
307
+ if (match === null) return null
308
+ const repaired = repairFrontmatterScalarFields(match[1])
309
+ if (repaired === null) return null
310
+ try {
311
+ load(repaired)
312
+ } catch {
313
+ return null
314
+ }
315
+ const body = match[2] ?? ''
316
+ return '---\n' + repaired + '\n---' + (body !== '' ? '\n' + body : '')
317
+ }
318
+
319
+ /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
320
+ export async function fixDiagnosticFile(path: string, home = dshHome()): Promise<string> {
321
+ const root = rootOfPath(path, home)
322
+ if (root === undefined) throw new TypeError('not a hub writable skill path: ' + path)
323
+ const text = await readFile(path, 'utf8')
324
+ const repairedText = repairFrontmatterFileText(text)
325
+ if (repairedText === null) throw new TypeError('diagnostic is not auto-fixable: ' + path)
326
+ // Validate the repaired text parses as a legal skill (prevents writing a still-broken file).
327
+ const parsed = parseFrontmatter(repairedText)
328
+ if ('error' in parsed) throw new TypeError('repaired frontmatter still invalid: ' + parsed.error)
329
+ await writeFile(path, repairedText, 'utf8')
330
+ return path
331
+ }
332
+
203
333
  /** Official boolean grammar: true/false, 1/0, and the common string spellings. */
204
334
  function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
205
335
  if (!Object.hasOwn(data, key)) return undefined
@@ -262,6 +392,55 @@ export function listSkillEntries(root: WritableRoot, home = dshHome()): Promise<
262
392
  return scanRoot(rootPath(root, home))
263
393
  }
264
394
 
395
+ /** Read UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
396
+ export async function readSkillInterface(directory: string): Promise<{ displayName?: string; shortDescription?: string; brandColor?: string; iconSmall?: string; iconLarge?: string; defaultPrompt?: string } | undefined> {
397
+ const yamlPath = join(directory, 'agents', 'openai.yaml')
398
+ let text: string
399
+ try {
400
+ text = await readFile(yamlPath, 'utf8')
401
+ } catch {
402
+ return undefined
403
+ }
404
+ let data: unknown
405
+ try {
406
+ data = load(text)
407
+ } catch {
408
+ return undefined
409
+ }
410
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
411
+ const record = data as Record<string, unknown>
412
+ const iface = (record.interface ?? record) as Record<string, unknown>
413
+ // codex stores interface under top-level `interface` key, but some templates put display_name at top level; support both.
414
+ const ifaceObj = typeof record.interface === 'object' && record.interface !== null && !Array.isArray(record.interface) ? record.interface as Record<string, unknown> : iface
415
+ const cleanStr = (value: unknown, maxLen: number): string | undefined => {
416
+ if (typeof value !== 'string') return undefined
417
+ const cleaned = value.split(/\s+/).join(' ').trim()
418
+ if (cleaned === '' || cleaned.length > maxLen) return undefined
419
+ return cleaned
420
+ }
421
+ const hexColor = (value: unknown): string | undefined => {
422
+ if (typeof value !== 'string') return undefined
423
+ const trimmed = value.trim()
424
+ return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined
425
+ }
426
+ const iconPath = (value: unknown): string | undefined => {
427
+ if (typeof value !== 'string' || value.trim() === '') return undefined
428
+ const trimmed = value.trim().replace(/\\/g, '/')
429
+ if (trimmed.startsWith('/') || trimmed.includes('..')) return undefined
430
+ if (!trimmed.startsWith('assets/')) return undefined
431
+ return trimmed
432
+ }
433
+ const displayName = cleanStr(ifaceObj.display_name, 64)
434
+ const shortDescription = cleanStr(ifaceObj.short_description, 1024)
435
+ const brandColor = hexColor(ifaceObj.brand_color)
436
+ const defaultPrompt = cleanStr(ifaceObj.default_prompt, 1024)
437
+ const iconSmall = iconPath(ifaceObj.icon_small)
438
+ const iconLarge = iconPath(ifaceObj.icon_large)
439
+ const hasAny = displayName !== undefined || shortDescription !== undefined || brandColor !== undefined || iconSmall !== undefined || iconLarge !== undefined || defaultPrompt !== undefined
440
+ if (!hasAny) return undefined
441
+ return { ...(displayName !== undefined ? { displayName } : {}), ...(shortDescription !== undefined ? { shortDescription } : {}), ...(brandColor !== undefined ? { brandColor } : {}), ...(iconSmall !== undefined ? { iconSmall } : {}), ...(iconLarge !== undefined ? { iconLarge } : {}), ...(defaultPrompt !== undefined ? { defaultPrompt } : {}) }
442
+ }
443
+
265
444
  /**
266
445
  * Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
267
446
  * falls back to cwd itself. The provider roots project skills here.
@@ -298,9 +477,15 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
298
477
  }
299
478
  const parsed = parseFrontmatter(text)
300
479
  if ('error' in parsed) {
301
- diagnostics.push({ path: entry.path, root, reason: parsed.error })
480
+ const fixable = repairFrontmatterFileText(text) !== null
481
+ diagnostics.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
302
482
  continue
303
483
  }
484
+ // File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
485
+ const repaired = repairFrontmatterFileText(text)
486
+ if (repaired !== null) {
487
+ diagnostics.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
488
+ }
304
489
  const { value } = parsed
305
490
  // The provider registers a skill by its discovery path (directory name or
306
491
  // flat file name), so a frontmatter name that diverges from the path makes