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.
- package/lib/client.js +890 -356
- package/lib/client.js.map +1 -1
- package/lib/index.js +401 -47
- package/lib/types/client/SkillHubSettingsCard.d.ts +0 -2
- package/lib/types/client/api.d.ts +5 -1
- package/lib/types/client/helpers.d.ts +10 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/panel/DisabledRow.d.ts +2 -0
- package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
- package/lib/types/client/panel/dialogs.d.ts +19 -0
- package/lib/types/client/panel/useSkillHub.d.ts +12 -1
- package/lib/types/client/settings-card.d.ts +0 -17
- package/lib/types/client/settings-form.d.ts +0 -1
- package/lib/types/protocol.d.ts +40 -17
- package/lib/types/repo.d.ts +4 -6
- package/lib/types/skillfs.d.ts +13 -0
- package/package.json +29 -28
- package/src/client/SkillHubSettingsCard.tsx +0 -2
- package/src/client/api.ts +18 -0
- package/src/client/helpers.ts +40 -0
- package/src/client/locales.ts +60 -0
- package/src/client/panel/DisabledRow.tsx +22 -7
- package/src/client/panel/MarketView.tsx +8 -1
- package/src/client/panel/ScenesView.tsx +1 -1
- package/src/client/panel/SkillDetailView.tsx +35 -11
- package/src/client/panel/SkillHubPanel.tsx +42 -4
- package/src/client/panel/SkillRow.tsx +5 -2
- package/src/client/panel/SourcesView.tsx +2 -17
- package/src/client/panel/dialogs.tsx +61 -0
- package/src/client/panel/panel.module.css +1 -8
- package/src/client/panel/useSkillHub.ts +97 -12
- package/src/client/settings-card.tsx +0 -42
- package/src/client/settings-form.ts +1 -5
- package/src/protocol.ts +42 -8
- package/src/repo.ts +71 -29
- package/src/routes.test.ts +22 -0
- package/src/routes.ts +181 -10
- 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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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 {
|
|
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) {
|
|
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(
|
|
176
|
+
data = load(rawFrontmatter)
|
|
176
177
|
} catch (error) {
|
|
177
|
-
|
|
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
|
-
|
|
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
|