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
@@ -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
- normalized.length === 0 || skill.name.toLocaleLowerCase().includes(normalized) || skill.description.toLocaleLowerCase().includes(normalized),
994
- ), [catalog, normalized])
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.userLayer()?.[field] === value
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
- export async function loadRepoTree(repo: string, ref?: string, fetchImpl: typeof fetch = fetch): Promise<{ ref: string; tree: RepoTreeItem[] }> {
132
- let metaResponse: Response
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
- metaResponse = await fetchImpl(`https://api.github.com/repos/${repo}`, {
135
- headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
136
- })
154
+ response = await fetchImpl(url, { headers })
137
155
  } catch (error) {
138
- throw new RepoFetchError('github request failed: ' + (error instanceof Error ? error.message : String(error)))
156
+ throw new RepoFetchError(context + ': ' + (error instanceof Error ? error.message : String(error)))
139
157
  }
140
- if (!metaResponse.ok) throw fetchError('github repo not found or unavailable', metaResponse)
141
-
142
- let meta: unknown
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
- meta = await metaResponse.json()
165
+ json = await response.json()
145
166
  } catch {
146
- throw new RepoFetchError('invalid github repo response')
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
- let treeResponse: Response
153
- try {
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
- if (treeRecord.truncated === true) throw new RepoFetchError('repo tree is too large to scan')
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
- return { ref: treeRef, tree }
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
@@ -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({