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
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
import type { SkillHubApi } from '../api.ts'
|
|
29
29
|
import { errorMessage, tt } from '../helpers.ts'
|
|
30
30
|
import { conflictsOnClose, isProjectSource, PRIVATE_SOURCE, sortSkills, type SortKey, type GroupSwitchState } from '../grouping.ts'
|
|
31
|
-
import type { BranchChoiceState, ConfirmDialogState, ConflictDialogState, MarketSyncDialogState } from './dialogs.tsx'
|
|
31
|
+
import type { BranchChoiceState, ConfirmDialogState, ConflictDialogState, MarketSyncDialogState, VersionChoiceState } from './dialogs.tsx'
|
|
32
32
|
|
|
33
33
|
/** Catalog poll interval while the panel is mounted (the provider watcher feeds this). */
|
|
34
34
|
const POLL_MS = 5000
|
|
@@ -118,6 +118,7 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
118
118
|
const [tab, setTab] = useState<'sources' | 'scenes' | 'market'>('sources')
|
|
119
119
|
const [skillView, setSkillView] = useState<'flat' | 'groups'>('groups')
|
|
120
120
|
const [sourceFilter, setSourceFilter] = useState('all')
|
|
121
|
+
const [invocationFilter, setInvocationFilter] = useState<'all' | 'model' | 'user'>('all')
|
|
121
122
|
const [sortKey, setSortKey] = useState<SortKey>('name')
|
|
122
123
|
const [marketState, setMarketState] = useState<MarketState>({ status: 'loading', repos: [] })
|
|
123
124
|
const [marketCheck, setMarketCheck] = useState<Readonly<Record<string, MarketCheckResult>>>({})
|
|
@@ -145,8 +146,9 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
145
146
|
const [newTagName, setNewTagName] = useState('')
|
|
146
147
|
const [tagBusy, setTagBusy] = useState(false)
|
|
147
148
|
const [editSearch, setEditSearch] = useState('')
|
|
148
|
-
/** 分组视图里收起的分组(key 为 tag:<id>、col:<name> 或 project
|
|
149
|
+
/** 分组视图里收起的分组(key 为 tag:<id>、col:<name> 或 project 树键)。技能总数 >80 时首次加载自动折叠 personal。 */
|
|
149
150
|
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(new Set())
|
|
151
|
+
const autoCollapsedPersonal = useRef(false)
|
|
150
152
|
/** 项目级三级树里已细分(按 .dsh/.agents)的项目键。 */
|
|
151
153
|
const [subdividedProjects, setSubdividedProjects] = useState<ReadonlySet<string>>(new Set())
|
|
152
154
|
const [showLegend, setShowLegend] = useState(false)
|
|
@@ -174,6 +176,10 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
174
176
|
try {
|
|
175
177
|
const next = await api.catalog(workspace !== '' ? { cwd: workspace } : undefined)
|
|
176
178
|
setCatalog(next)
|
|
179
|
+
if (!autoCollapsedPersonal.current && next.skills.length + next.disabled.length > 80) {
|
|
180
|
+
autoCollapsedPersonal.current = true
|
|
181
|
+
setCollapsedGroups((previous) => new Set(previous).add('uncategorized-source'))
|
|
182
|
+
}
|
|
177
183
|
setLoadError(null)
|
|
178
184
|
} catch (error) {
|
|
179
185
|
setLoadError(errorMessage(error))
|
|
@@ -466,6 +472,27 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
466
472
|
}
|
|
467
473
|
}, [api, load, loadSources])
|
|
468
474
|
|
|
475
|
+
const [fixingPaths, setFixingPaths] = useState<ReadonlySet<string>>(new Set())
|
|
476
|
+
const fixDiagnostic = useCallback(async (path: string): Promise<void> => {
|
|
477
|
+
setFixingPaths((previous) => new Set(previous).add(path))
|
|
478
|
+
setLoadError(null)
|
|
479
|
+
try {
|
|
480
|
+
const confirmed = window.confirm(tt('diag.fixConfirm', { path }))
|
|
481
|
+
if (!confirmed) return
|
|
482
|
+
await api.fixDiagnostic(path)
|
|
483
|
+
await load()
|
|
484
|
+
setSuccessBanner(tt('diag.fixed'))
|
|
485
|
+
} catch (error) {
|
|
486
|
+
setLoadError(errorMessage(error))
|
|
487
|
+
} finally {
|
|
488
|
+
setFixingPaths((previous) => {
|
|
489
|
+
const next = new Set(previous)
|
|
490
|
+
next.delete(path)
|
|
491
|
+
return next
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
}, [api, load])
|
|
495
|
+
|
|
469
496
|
/** 打开单个技能的删除确认(移入回收站,可恢复)。 */
|
|
470
497
|
const requestDeleteSkill = useCallback((name: string): void => {
|
|
471
498
|
setDeleteSkillDialog(name)
|
|
@@ -722,12 +749,14 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
722
749
|
return
|
|
723
750
|
}
|
|
724
751
|
setRepoDiscoverState({ status: 'ready', data })
|
|
752
|
+
// The discover route may have auto-pinned the release; refresh the list so the ref badge shows.
|
|
753
|
+
void loadMarket()
|
|
725
754
|
} catch (error) {
|
|
726
755
|
setRepoDiscoverState({ status: 'error', message: errorMessage(error) })
|
|
727
756
|
} finally {
|
|
728
757
|
setScanningRepo(null)
|
|
729
758
|
}
|
|
730
|
-
}, [api])
|
|
759
|
+
}, [api, loadMarket])
|
|
731
760
|
|
|
732
761
|
/** 确认分支选择:持久化 ref 后重新扫描。 */
|
|
733
762
|
const confirmBranchChoice = useCallback(async (): Promise<void> => {
|
|
@@ -745,6 +774,47 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
745
774
|
}
|
|
746
775
|
}, [api, branchChoice, scanRepo])
|
|
747
776
|
|
|
777
|
+
/** 版本对话框:打开时拉 releases + branches,确认后定版并重扫。 */
|
|
778
|
+
const [versionDialog, setVersionDialog] = useState<VersionChoiceState | null>(null)
|
|
779
|
+
const [versionBusy, setVersionBusy] = useState(false)
|
|
780
|
+
const openVersionDialog = useCallback(async (repo: string): Promise<void> => {
|
|
781
|
+
const current = marketState.repos.find((item) => item.repo === repo)?.ref
|
|
782
|
+
setVersionDialog({ repo, ...(current !== undefined ? { current } : {}), releases: [], branches: [], selected: current ?? '', custom: '', loading: true })
|
|
783
|
+
setLoadError(null)
|
|
784
|
+
try {
|
|
785
|
+
const data = await api.marketSourceVersions(repo)
|
|
786
|
+
setVersionDialog({
|
|
787
|
+
repo,
|
|
788
|
+
...(data.current !== undefined ? { current: data.current } : {}),
|
|
789
|
+
releases: data.releases,
|
|
790
|
+
branches: data.branches,
|
|
791
|
+
selected: data.current ?? data.releases[0] ?? data.branches[0] ?? '',
|
|
792
|
+
custom: '',
|
|
793
|
+
loading: false,
|
|
794
|
+
})
|
|
795
|
+
} catch (error) {
|
|
796
|
+
setLoadError(errorMessage(error))
|
|
797
|
+
setVersionDialog(null)
|
|
798
|
+
}
|
|
799
|
+
}, [api, marketState])
|
|
800
|
+
const confirmVersionDialog = useCallback(async (): Promise<void> => {
|
|
801
|
+
if (versionDialog === null) return
|
|
802
|
+
const ref = versionDialog.custom.trim() !== '' ? versionDialog.custom.trim() : versionDialog.selected
|
|
803
|
+
if (ref === '') return
|
|
804
|
+
setVersionBusy(true)
|
|
805
|
+
setLoadError(null)
|
|
806
|
+
try {
|
|
807
|
+
await api.setMarketSourceRef(versionDialog.repo, ref)
|
|
808
|
+
setVersionDialog(null)
|
|
809
|
+
await loadMarket()
|
|
810
|
+
await scanRepo(versionDialog.repo)
|
|
811
|
+
} catch (error) {
|
|
812
|
+
setLoadError(errorMessage(error))
|
|
813
|
+
} finally {
|
|
814
|
+
setVersionBusy(false)
|
|
815
|
+
}
|
|
816
|
+
}, [api, versionDialog, loadMarket, scanRepo])
|
|
817
|
+
|
|
748
818
|
/** Toggle one repo preview row. */
|
|
749
819
|
const toggleRepoSelected = useCallback((path: string, checked: boolean): void => {
|
|
750
820
|
setRepoSelected((previous) => {
|
|
@@ -989,29 +1059,44 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
989
1059
|
const hasPrivate = skills.some((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
|
|
990
1060
|
return [...repos, ...(hasPrivate ? [PRIVATE_SOURCE] : [])]
|
|
991
1061
|
}, [catalog, origins])
|
|
992
|
-
const filtered = useMemo(() => (catalog?.skills ?? []).filter((skill) =>
|
|
993
|
-
|
|
994
|
-
|
|
1062
|
+
const filtered = useMemo(() => (catalog?.skills ?? []).filter((skill) => {
|
|
1063
|
+
if (invocationFilter === 'model' && !skill.invocation.modelInvocable) return false
|
|
1064
|
+
if (invocationFilter === 'user' && !skill.invocation.userInvocable) return false
|
|
1065
|
+
if (normalized.length === 0) return true
|
|
1066
|
+
return skill.name.toLocaleLowerCase().includes(normalized)
|
|
1067
|
+
|| skill.description.toLocaleLowerCase().includes(normalized)
|
|
1068
|
+
|| skill.displayName?.toLocaleLowerCase().includes(normalized)
|
|
1069
|
+
|| skill.shortDescription?.toLocaleLowerCase().includes(normalized)
|
|
1070
|
+
}), [catalog, normalized, invocationFilter])
|
|
1071
|
+
/** Rows actually rendered with a shortened description (shortDescription in use). */
|
|
1072
|
+
const shortenedCount = useMemo(() => filtered.filter((skill) => skill.shortDescription !== undefined).length, [filtered])
|
|
995
1073
|
/** 排序后的技能列表(所有视图共用;调用次数未知按 0 处理)。 */
|
|
996
1074
|
const sorted = useMemo(() => sortSkills(filtered, sortKey, (name) => uses.get(name)?.count), [filtered, sortKey, uses])
|
|
997
1075
|
|
|
1076
|
+
/** Clear the list filters (search + source + invocation) back to the full view. */
|
|
1077
|
+
const clearListFilters = useCallback((): void => {
|
|
1078
|
+
setSearch('')
|
|
1079
|
+
setSourceFilter('all')
|
|
1080
|
+
setInvocationFilter('all')
|
|
1081
|
+
}, [])
|
|
1082
|
+
|
|
998
1083
|
return {
|
|
999
1084
|
// state
|
|
1000
1085
|
catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult, importJobId,
|
|
1001
1086
|
search, workspace, detail, detailLoading, busyNames, batchBusy, showForm, formName, formDesc, formRoot, formBusy, formMessage,
|
|
1002
|
-
uses, hubConfig, tab, skillView, sourceFilter, sortKey, marketState, marketCheck, branchChoice, branchBusy,
|
|
1087
|
+
uses, hubConfig, tab, skillView, sourceFilter, invocationFilter, sortKey, marketState, marketCheck, branchChoice, branchBusy,
|
|
1003
1088
|
marketSyncDialog, syncingMarket, syncBusy, newSourceName, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource,
|
|
1004
1089
|
conflictDialog, confirmDialog, deleteSkillDialog, deleteGroupDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
|
|
1005
|
-
editSearch, collapsedGroups, subdividedProjects, showLegend, editMode,
|
|
1090
|
+
editSearch, collapsedGroups, subdividedProjects, showLegend, editMode, versionDialog, versionBusy,
|
|
1006
1091
|
// derived
|
|
1007
|
-
actionNames, viewNames, normalized, origins, sourceOptions, filtered, sorted,
|
|
1092
|
+
actionNames, viewNames, normalized, origins, sourceOptions, filtered, sorted, shortenedCount,
|
|
1008
1093
|
// actions + setters
|
|
1009
1094
|
setLoadError, setSuccessBanner, setSearch, setWorkspace, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage,
|
|
1010
1095
|
setRepoSelected, setTab, setSkillView,
|
|
1011
|
-
setSourceFilter, setSortKey, setBranchChoice, setMarketSyncDialog, setNewSourceName, setConflictDialog, setConfirmDialog,
|
|
1012
|
-
setDeleteSkillDialog, setDeleteGroupDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend, setEditMode,
|
|
1096
|
+
setSourceFilter, setInvocationFilter, setSortKey, setBranchChoice, setMarketSyncDialog, setNewSourceName, setConflictDialog, setConfirmDialog,
|
|
1097
|
+
setDeleteSkillDialog, setDeleteGroupDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend, setEditMode, setVersionDialog,
|
|
1013
1098
|
toggleGroupCollapse, toggleSubdivide, checkUpdate, loadMarket, openDetail, toggle, enableDisabled, batchToggleNames, toggleGroup, resolveConflict,
|
|
1014
|
-
runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, requestDeleteSkill, runDeleteSkill, requestDeleteGroup, runDeleteGroup, createTag,
|
|
1099
|
+
runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, fixingPaths, fixDiagnostic, clearListFilters, openVersionDialog, confirmVersionDialog, requestDeleteSkill, runDeleteSkill, requestDeleteGroup, runDeleteGroup, createTag,
|
|
1015
1100
|
deleteTag, saveTag, reorderTags, reorderCollections, reorderSourceGroups, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo, cancelImport, clearScan,
|
|
1016
1101
|
checkMarket, syncMarketSource, confirmMarketSync, updateAll, create,
|
|
1017
1102
|
}
|
|
@@ -89,48 +89,6 @@ export function PluginSettingsCard(props: PluginSettingsCardProps): ReactElement
|
|
|
89
89
|
)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** One staged boolean field: 继承 / 开 / 关. */
|
|
93
|
-
export interface BooleanFieldProps {
|
|
94
|
-
id: string
|
|
95
|
-
label: string
|
|
96
|
-
hint: string
|
|
97
|
-
inheritLabel: string
|
|
98
|
-
onLabel: string
|
|
99
|
-
offLabel: string
|
|
100
|
-
overriddenLabel: string
|
|
101
|
-
resetLabel: string
|
|
102
|
-
disabled: boolean
|
|
103
|
-
text: string
|
|
104
|
-
overridden: boolean
|
|
105
|
-
onEdit: (text: string) => void
|
|
106
|
-
onReset: () => void
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function BooleanField(props: BooleanFieldProps): ReactElement {
|
|
110
|
-
return (
|
|
111
|
-
<div className={css.field}>
|
|
112
|
-
<div className={css.head}>
|
|
113
|
-
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
114
|
-
{props.overridden ? (
|
|
115
|
-
<span className={css.badges}>
|
|
116
|
-
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
117
|
-
<button type='button' className={css.reset} disabled={props.disabled} onClick={props.onReset}>
|
|
118
|
-
{props.resetLabel}
|
|
119
|
-
</button>
|
|
120
|
-
</span>
|
|
121
|
-
) : null}
|
|
122
|
-
</div>
|
|
123
|
-
<select id={props.id} className={css.input} value={props.text} disabled={props.disabled} onChange={(event) => { props.onEdit(event.target.value) }}>
|
|
124
|
-
<option value=''>{props.inheritLabel}</option>
|
|
125
|
-
<option value='true'>{props.onLabel}</option>
|
|
126
|
-
<option value='false'>{props.offLabel}</option>
|
|
127
|
-
</select>
|
|
128
|
-
<p className={css.hint}>{props.hint}</p>
|
|
129
|
-
</div>
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
92
|
/**
|
|
135
93
|
* A compact sliding switch for the card's master enable/disable control.
|
|
136
94
|
* An empty text value means the field inherits its default; the switch still
|
|
@@ -204,7 +204,7 @@ export class CardForm {
|
|
|
204
204
|
|
|
205
205
|
private async store(field: string, value: unknown): Promise<boolean> {
|
|
206
206
|
await this.scope.set(field, value)
|
|
207
|
-
return this.
|
|
207
|
+
return this.userRecord()?.[field] === value
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
private userRecord(): Record<string, unknown> | undefined {
|
|
@@ -233,10 +233,6 @@ export class CardForm {
|
|
|
233
233
|
return typeof base === 'object' && base !== null ? (base as Record<string, unknown>)[field] : undefined
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
private userLayer(): Record<string, unknown> | undefined {
|
|
237
|
-
return this.userRecord()
|
|
238
|
-
}
|
|
239
|
-
|
|
240
236
|
private stored(field: string): boolean {
|
|
241
237
|
const user = this.userRecord()
|
|
242
238
|
return user !== undefined && Object.hasOwn(user, field)
|
package/src/protocol.ts
CHANGED
|
@@ -39,6 +39,8 @@ export const SKILL_HUB_API = {
|
|
|
39
39
|
sourceDelete: '/api/skill-hub/sources/delete',
|
|
40
40
|
sourceRestore: '/api/skill-hub/sources/restore',
|
|
41
41
|
sourceTrashClear: '/api/skill-hub/sources/trash/clear',
|
|
42
|
+
diagnosticFix: '/api/skill-hub/diagnostic/fix',
|
|
43
|
+
marketSourceVersions: '/api/skill-hub/market/source/versions',
|
|
42
44
|
} as const
|
|
43
45
|
|
|
44
46
|
/** User-level roots the hub may write to (matches dsh-skill-filesystem ranks 400/500). */
|
|
@@ -71,6 +73,13 @@ export interface CatalogSkill {
|
|
|
71
73
|
workspace?: string
|
|
72
74
|
/** 工作区显示标题(来自 workspace.json;无则回退为路径)。 */
|
|
73
75
|
workspaceTitle?: string
|
|
76
|
+
/** UI metadata from agents/openai.yaml (mirrors codex SkillInterface). */
|
|
77
|
+
displayName?: string
|
|
78
|
+
shortDescription?: string
|
|
79
|
+
brandColor?: string
|
|
80
|
+
iconSmall?: string
|
|
81
|
+
iconLarge?: string
|
|
82
|
+
defaultPrompt?: string
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
/** One disabled skill tracked by the hub sidecar (SKILL.md renamed away). */
|
|
@@ -88,6 +97,17 @@ export interface DiagnosticEntry {
|
|
|
88
97
|
path: string
|
|
89
98
|
root: string
|
|
90
99
|
reason: string
|
|
100
|
+
/** Whether this diagnostic can be auto-fixed (e.g. unquoted colon). */
|
|
101
|
+
fixable?: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** POST /api/skill-hub/diagnostic/fix — repair a fixable diagnostic in place. */
|
|
105
|
+
export interface DiagnosticFixRequest {
|
|
106
|
+
path: string
|
|
107
|
+
}
|
|
108
|
+
export interface DiagnosticFixResponse {
|
|
109
|
+
ok: true
|
|
110
|
+
path: string
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
/** GET /api/skill-hub/catalog */
|
|
@@ -103,6 +123,8 @@ export interface CatalogResponse {
|
|
|
103
123
|
disabled: DisabledSkill[]
|
|
104
124
|
/** Files in the writable roots the provider ignores, with reasons. */
|
|
105
125
|
diagnostics: DiagnosticEntry[]
|
|
126
|
+
/** Skill names that appeared in multiple roots (first wins, others hidden). Mirrors codex name_counts. */
|
|
127
|
+
duplicateNames?: string[]
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
/** GET /api/skill-hub/skill */
|
|
@@ -120,6 +142,12 @@ export interface SkillDetail {
|
|
|
120
142
|
updatedAt?: number
|
|
121
143
|
/** Markdown instruction body. */
|
|
122
144
|
content: string
|
|
145
|
+
displayName?: string
|
|
146
|
+
shortDescription?: string
|
|
147
|
+
brandColor?: string
|
|
148
|
+
iconSmall?: string
|
|
149
|
+
iconLarge?: string
|
|
150
|
+
defaultPrompt?: string
|
|
123
151
|
}
|
|
124
152
|
|
|
125
153
|
export interface SkillDetailResponse {
|
|
@@ -389,6 +417,18 @@ export interface MarketSourceRefRequest {
|
|
|
389
417
|
ref: string
|
|
390
418
|
}
|
|
391
419
|
|
|
420
|
+
/** GET /api/skill-hub/market/source/versions?repo= — releases + branches for the version picker. */
|
|
421
|
+
export interface MarketSourceVersionsResponse {
|
|
422
|
+
ok: true
|
|
423
|
+
repo: string
|
|
424
|
+
/** Currently pinned ref, when one is recorded. */
|
|
425
|
+
current?: string
|
|
426
|
+
/** Release tags, newest first (drafts excluded). */
|
|
427
|
+
releases: string[]
|
|
428
|
+
/** Branch names, default branch first. */
|
|
429
|
+
branches: string[]
|
|
430
|
+
}
|
|
431
|
+
|
|
392
432
|
/** GET /api/skill-hub/market/check — update check over market sources. */
|
|
393
433
|
export interface MarketCheckResponse {
|
|
394
434
|
ok: true
|
|
@@ -455,6 +495,8 @@ export interface RepoDiscoverResponse {
|
|
|
455
495
|
/** Branch names to choose from when ref is null (default branch first). */
|
|
456
496
|
branches?: string[]
|
|
457
497
|
entries: RepoSkillEntry[]
|
|
498
|
+
/** GitHub truncated the tree; discovery is partial (mirrors codex walk_truncated). */
|
|
499
|
+
truncated?: boolean
|
|
458
500
|
}
|
|
459
501
|
|
|
460
502
|
/** POST /api/skill-hub/repo/import — install selected repo skills. */
|
|
@@ -507,14 +549,6 @@ export interface RepoImportCancelResponse {
|
|
|
507
549
|
status: 'cancelled' | 'done'
|
|
508
550
|
}
|
|
509
551
|
|
|
510
|
-
/** @deprecated 旧同步响应,保留供类型兼容,实际已由 Progress 代替 */
|
|
511
|
-
export interface RepoImportLegacyResponse {
|
|
512
|
-
ok: true
|
|
513
|
-
imported: Array<{ name: string; origin: string; path: string }>
|
|
514
|
-
skipped: Array<{ name: string; reason: 'exists' }>
|
|
515
|
-
failed: Array<{ name: string; error: string }>
|
|
516
|
-
}
|
|
517
|
-
|
|
518
552
|
/** GET /api/skill-hub/update — check the plugin's own latest GitHub release. */
|
|
519
553
|
export interface UpdateCheckResponse {
|
|
520
554
|
ok: true
|
package/src/repo.ts
CHANGED
|
@@ -67,6 +67,23 @@ export function githubAuthHeaders(): Record<string, string> {
|
|
|
67
67
|
return githubToken === '' ? {} : { authorization: 'Bearer ' + githubToken }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
|
|
71
|
+
const etagCache = new Map<string, { etag: string; json: unknown }>()
|
|
72
|
+
const ETAG_MAX = 200
|
|
73
|
+
function etagCacheGet(url: string): { etag: string; json: unknown } | undefined {
|
|
74
|
+
return etagCache.get(url)
|
|
75
|
+
}
|
|
76
|
+
function etagCacheSet(url: string, etag: string, json: unknown): void {
|
|
77
|
+
if (etagCache.size >= ETAG_MAX) {
|
|
78
|
+
const first = etagCache.keys().next().value as string | undefined
|
|
79
|
+
if (first !== undefined) etagCache.delete(first)
|
|
80
|
+
}
|
|
81
|
+
etagCache.set(url, { etag, json })
|
|
82
|
+
}
|
|
83
|
+
export function clearEtagCache(): void {
|
|
84
|
+
etagCache.clear()
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
/**
|
|
71
88
|
* Build a RepoFetchError; when the response shows an exhausted rate limit,
|
|
72
89
|
* report the reset time instead of a bare HTTP status.
|
|
@@ -128,49 +145,50 @@ export function normalizeRepoInput(input: string): RepoRef | null {
|
|
|
128
145
|
* tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
|
|
129
146
|
* the exact same content a later download would fetch.
|
|
130
147
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
148
|
+
async function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{ json: unknown; response: Response }> {
|
|
149
|
+
const cached = etagCacheGet(url)
|
|
150
|
+
const headers: Record<string, string> = { accept: 'application/vnd.github+json', ...githubAuthHeaders() }
|
|
151
|
+
if (cached !== undefined) headers['if-none-match'] = cached.etag
|
|
152
|
+
let response: Response
|
|
133
153
|
try {
|
|
134
|
-
|
|
135
|
-
headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
|
|
136
|
-
})
|
|
154
|
+
response = await fetchImpl(url, { headers })
|
|
137
155
|
} catch (error) {
|
|
138
|
-
throw new RepoFetchError(
|
|
156
|
+
throw new RepoFetchError(context + ': ' + (error instanceof Error ? error.message : String(error)))
|
|
139
157
|
}
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
158
|
+
if (response.status === 304 && cached !== undefined) {
|
|
159
|
+
// Return cached JSON with a synthetic 200-like response for error mapping.
|
|
160
|
+
return { json: cached.json, response }
|
|
161
|
+
}
|
|
162
|
+
if (!response.ok) throw fetchError(context, response)
|
|
163
|
+
let json: unknown
|
|
143
164
|
try {
|
|
144
|
-
|
|
165
|
+
json = await response.json()
|
|
145
166
|
} catch {
|
|
146
|
-
throw new RepoFetchError('invalid github
|
|
167
|
+
throw new RepoFetchError('invalid github response for ' + url)
|
|
147
168
|
}
|
|
169
|
+
const etag = response.headers.get('etag')
|
|
170
|
+
if (etag !== null && etag !== '') etagCacheSet(url, etag, json)
|
|
171
|
+
return { json, response }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function loadRepoTree(repo: string, ref?: string, fetchImpl: typeof fetch = fetch): Promise<{ ref: string; tree: RepoTreeItem[]; truncated: boolean }> {
|
|
175
|
+
const metaUrl = `https://api.github.com/repos/${repo}`
|
|
176
|
+
const { json: metaJson } = await fetchJsonCached(metaUrl, fetchImpl, 'github repo not found or unavailable')
|
|
177
|
+
let meta: unknown = metaJson
|
|
148
178
|
const record = typeof meta === 'object' && meta !== null ? meta as Record<string, unknown> : {}
|
|
149
179
|
const defaultBranch = typeof record.default_branch === 'string' && record.default_branch !== '' ? record.default_branch : 'main'
|
|
150
180
|
const treeRef = ref !== undefined && ref !== '' ? ref : defaultBranch
|
|
151
181
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
treeResponse = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeRef)}?recursive=1`, {
|
|
155
|
-
headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
|
|
156
|
-
})
|
|
157
|
-
} catch (error) {
|
|
158
|
-
throw new RepoFetchError('github tree request failed: ' + (error instanceof Error ? error.message : String(error)))
|
|
159
|
-
}
|
|
160
|
-
if (!treeResponse.ok) throw fetchError('github tree not found', treeResponse)
|
|
161
|
-
|
|
162
|
-
let payload: unknown
|
|
163
|
-
try {
|
|
164
|
-
payload = await treeResponse.json()
|
|
165
|
-
} catch {
|
|
166
|
-
throw new RepoFetchError('invalid github tree response')
|
|
167
|
-
}
|
|
182
|
+
const treeUrl = `https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeRef)}?recursive=1`
|
|
183
|
+
const { json: payload } = await fetchJsonCached(treeUrl, fetchImpl, 'github tree not found')
|
|
168
184
|
const treeRecord = typeof payload === 'object' && payload !== null ? payload as Record<string, unknown> : {}
|
|
169
|
-
|
|
185
|
+
const truncated = treeRecord.truncated === true
|
|
170
186
|
const tree = Array.isArray(treeRecord.tree) ? treeRecord.tree as RepoTreeItem[] : []
|
|
171
187
|
// Report the ref the tree was actually fetched at: downloaders must use
|
|
172
188
|
// this exact value so an explicit branch import never mixes trees.
|
|
173
|
-
|
|
189
|
+
// When truncated is true GitHub returned a partial tree; callers should
|
|
190
|
+
// surface a warning but still allow the partial discovery (mirrors codex walk_truncated).
|
|
191
|
+
return { ref: treeRef, tree, truncated }
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
/** Collect every file inside a skill directory, including SKILL.md itself. */
|
|
@@ -409,6 +427,30 @@ export async function getLatestReleaseTag(repo: string, fetchImpl: typeof fetch
|
|
|
409
427
|
return typeof tag === 'string' && tag !== '' ? tag : undefined
|
|
410
428
|
}
|
|
411
429
|
|
|
430
|
+
/** Release tags of a repo, newest first (skips drafts, keeps prereleases). */
|
|
431
|
+
export async function listRepoReleases(repo: string, fetchImpl: typeof fetch = fetch): Promise<string[]> {
|
|
432
|
+
let response: Response
|
|
433
|
+
try {
|
|
434
|
+
response = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, {
|
|
435
|
+
headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
|
|
436
|
+
})
|
|
437
|
+
} catch (error) {
|
|
438
|
+
throw new RepoFetchError('github releases request failed: ' + (error instanceof Error ? error.message : String(error)))
|
|
439
|
+
}
|
|
440
|
+
if (!response.ok) throw fetchError('github releases lookup failed', response)
|
|
441
|
+
let payload: unknown
|
|
442
|
+
try {
|
|
443
|
+
payload = await response.json()
|
|
444
|
+
} catch {
|
|
445
|
+
throw new RepoFetchError('invalid github releases response')
|
|
446
|
+
}
|
|
447
|
+
if (!Array.isArray(payload)) return []
|
|
448
|
+
return payload
|
|
449
|
+
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null && (item as Record<string, unknown>).draft !== true)
|
|
450
|
+
.map((item) => item.tag_name)
|
|
451
|
+
.filter((tag): tag is string => typeof tag === 'string' && tag !== '')
|
|
452
|
+
}
|
|
453
|
+
|
|
412
454
|
/** Branch names of a repo, default branch first (one GitHub API request). */
|
|
413
455
|
export async function listRepoBranches(repo: string, fetchImpl: typeof fetch = fetch): Promise<string[]> {
|
|
414
456
|
let response: Response
|
package/src/routes.test.ts
CHANGED
|
@@ -178,6 +178,28 @@ describe('skill-hub routes', () => {
|
|
|
178
178
|
expect(byName.get('shared-user')?.workspace).toBeUndefined()
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
it('flags duplicate names only across distinct source/provider identities', async () => {
|
|
182
|
+
// 两个工作区快照返回同一个用户级技能:同一来源+提供者,不算重名。
|
|
183
|
+
await mkdir(join(home, 'storages'), { recursive: true })
|
|
184
|
+
await writeFile(join(home, 'storages', 'workspace.json'), JSON.stringify({
|
|
185
|
+
tables: { workspaces: { a: { title: 'Alpha', path: '/ws/a' }, b: { title: 'Beta', path: '/ws/b' } } },
|
|
186
|
+
}), 'utf8')
|
|
187
|
+
skills.snapshot = async () => ({
|
|
188
|
+
skills: [
|
|
189
|
+
summary({ name: 'shared-user', source: 'user-dsh', provider: 'skill-hub' }),
|
|
190
|
+
summary({ name: 'two-homes', source: 'custom', provider: 'openviking' }),
|
|
191
|
+
summary({ name: 'two-homes', source: 'user-dsh', provider: 'skill-hub' }),
|
|
192
|
+
],
|
|
193
|
+
complete: true,
|
|
194
|
+
})
|
|
195
|
+
const res = new FakeResponse()
|
|
196
|
+
await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog), res as never)
|
|
197
|
+
expect(res.status).toBe(200)
|
|
198
|
+
const body = res.json() as CatalogResponse
|
|
199
|
+
// shared-user 在两个快照出现但身份一致 → 不标;two-homes 来源不同 → 标。
|
|
200
|
+
expect(body.duplicateNames ?? []).toEqual(['two-homes'])
|
|
201
|
+
})
|
|
202
|
+
|
|
181
203
|
it('falls back across workspaces when fetching a project skill detail without cwd', async () => {
|
|
182
204
|
await mkdir(join(home, 'storages'), { recursive: true })
|
|
183
205
|
await writeFile(join(home, 'storages', 'workspace.json'), JSON.stringify({
|