dsh-skill-hub 0.2.0 → 0.2.2

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 (45) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +181 -249
  3. package/README.zh.md +275 -0
  4. package/lib/client.js +395 -257
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +126 -26
  7. package/lib/types/client/SkillHubSettingsCard.d.ts +23 -17
  8. package/lib/types/client/api.d.ts +9 -3
  9. package/lib/types/client/grouping.d.ts +3 -0
  10. package/lib/types/client/index.d.ts +12 -6
  11. package/lib/types/client/locales.d.ts +8 -4
  12. package/lib/types/client/market-catalog.d.ts +1 -1
  13. package/lib/types/client/panel/MarketView.d.ts +5 -5
  14. package/lib/types/client/panel/SkillHubPanel.d.ts +1 -1
  15. package/lib/types/client/panel/SourcesView.d.ts +5 -3
  16. package/lib/types/client/panel/useSkillHub.d.ts +4 -0
  17. package/lib/types/client/settings-card.d.ts +5 -4
  18. package/lib/types/index.d.ts +12 -1
  19. package/lib/types/protocol.d.ts +23 -0
  20. package/lib/types/skillfs.d.ts +1 -1
  21. package/lib/types/update.d.ts +1 -1
  22. package/package.json +28 -27
  23. package/src/client/SkillHubSettingsCard.tsx +25 -14
  24. package/src/client/api.ts +9 -6
  25. package/src/client/grouping.test.ts +12 -0
  26. package/src/client/grouping.ts +10 -1
  27. package/src/client/index.tsx +25 -19
  28. package/src/client/locales.ts +16 -8
  29. package/src/client/market-catalog.ts +1 -1
  30. package/src/client/panel/MarketView.tsx +77 -64
  31. package/src/client/panel/SkillHubPanel.tsx +18 -1
  32. package/src/client/panel/SkillRow.tsx +17 -2
  33. package/src/client/panel/SourcesView.tsx +92 -5
  34. package/src/client/panel/panel.module.css +9 -1
  35. package/src/client/panel/useSkillHub.ts +25 -11
  36. package/src/client/settings-card.tsx +5 -4
  37. package/src/index.ts +78 -31
  38. package/src/protocol.ts +24 -0
  39. package/src/routes.test.ts +55 -0
  40. package/src/routes.ts +90 -10
  41. package/src/skillfs.ts +1 -1
  42. package/src/update.ts +1 -1
  43. package/lib/types/client/api-config-scope.d.ts +0 -48
  44. package/src/client/api-config-scope.test.ts +0 -79
  45. package/src/client/api-config-scope.ts +0 -119
@@ -2,7 +2,7 @@
2
2
  * The skill hub panel: catalog grouped by tags + source collections, search
3
3
  * and filter in one row, per-group tri-state switches with conflict dialogs,
4
4
  * upstream source tracking (check / sync / follow upstream deletion into a
5
- * restorable trash), codex-style market sources, disabled re-enable,
5
+ * restorable trash), market sources, disabled re-enable,
6
6
  * detail inspection, and the new-skill scaffold form.
7
7
  *
8
8
  * Thin shell: state and flows live in useSkillHub, the tab contents live in
@@ -12,6 +12,7 @@
12
12
  * so the detail / tag-editor early returns are safe.
13
13
  */
14
14
 
15
+ import { useState } from 'react'
15
16
  import type { WritableRoot } from '../../protocol.ts'
16
17
  import { IconSkillOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
17
18
  import type { SkillHubApi } from '../api.ts'
@@ -34,9 +35,12 @@ export interface SkillHubPanelProps {
34
35
 
35
36
  export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
36
37
  const hub = useSkillHub(props.api)
38
+ /** 工作区输入草稿:回车才应用,避免每次按键都触发目录重拉。 */
39
+ const [workspaceDraft, setWorkspaceDraft] = useState('')
37
40
  const {
38
41
  catalog, loading, loadError, successBanner, updateState, detail, detailLoading, showForm, formName, formDesc,
39
42
  formRoot, formBusy, formMessage, hubConfig, tab, skillView, sourceFilter, sortKey, search,
43
+ workspace, setWorkspace,
40
44
  sourcesState, tagBusy, busyNames, normalized, origins, sourceOptions, filtered,
41
45
  conflictDialog, confirmDialog, deleteSkillDialog, confirmClearTrash, branchChoice, branchBusy, marketSyncDialog,
42
46
  syncBusy, editingTag, editName, membersDraft, editSearch, uses, groupsState, sourceCheck, checkingSource, syncingSource,
@@ -136,6 +140,19 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
136
140
  <button type='button' className={css.segBtn + (tab === 'scenes' ? ' ' + css.segBtnActive : '')} onClick={() => { setTab('scenes') }}>{tt('view.scenes')}</button>
137
141
  <button type='button' className={css.segBtn + (tab === 'market' ? ' ' + css.segBtnActive : '')} onClick={() => { setTab('market'); void loadMarket() }}>{tt('view.market')}</button>
138
142
  </span>
143
+ <span className={css.workspaceBox}>
144
+ <input
145
+ className={css.search + ' ' + css.workspaceInput}
146
+ value={workspaceDraft}
147
+ placeholder={workspace !== '' ? workspace : tt('panel.workspacePlaceholder')}
148
+ title={tt('panel.workspaceHint')}
149
+ onChange={(event) => { setWorkspaceDraft(event.target.value) }}
150
+ onKeyDown={(event) => { if (event.key === 'Enter') setWorkspace((event.target as HTMLInputElement).value.trim()) }}
151
+ />
152
+ {workspace !== ''
153
+ ? <button type='button' className={css.opBtn} title={tt('panel.workspaceClear')} onClick={() => { setWorkspace(''); setWorkspaceDraft('') }}>✕</button>
154
+ : null}
155
+ </span>
139
156
  <button type='button' className={css.legendToggle + (showLegend ? ' ' + css.legendToggleActive : '')} onClick={() => { setShowLegend((value) => !value) }} title={tt('legend.hint')}>?</button>
140
157
  <span className={css.actions}>
141
158
  <button type='button' className={css.button + ' ' + css.primary} onClick={() => { setShowForm((value) => !value) }}>{tt('panel.new')}</button>
@@ -3,7 +3,7 @@
3
3
  * disable actions. Shared by the flat list and both grouped views.
4
4
  */
5
5
 
6
- import type { JSX } from 'react'
6
+ import type { JSX, KeyboardEvent } from 'react'
7
7
  import type { CatalogSkill } from '../../protocol.ts'
8
8
  import { IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
9
9
  import { tt } from '../helpers.ts'
@@ -16,8 +16,23 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
16
16
  const stat = hub.uses.get(skill.name)
17
17
  const count = stat?.count ?? 0
18
18
  const lastUsed = stat?.lastUsed
19
+ /** 键盘打开详情:Enter 或空格。仅当焦点在行本身(而非行内按钮)时生效。 */
20
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
21
+ if (event.target !== event.currentTarget) return
22
+ if (event.key === 'Enter' || event.key === ' ') {
23
+ event.preventDefault()
24
+ void hub.openDetail(skill.name)
25
+ }
26
+ }
19
27
  return (
20
- <div className={css.row} onClick={() => { void hub.openDetail(skill.name) }}>
28
+ <div
29
+ className={css.row}
30
+ role='button'
31
+ tabIndex={0}
32
+ aria-label={tt('row.open', { name: skill.name })}
33
+ onClick={() => { void hub.openDetail(skill.name) }}
34
+ onKeyDown={onKeyDown}
35
+ >
21
36
  <div className={css.rowMain}>
22
37
  <div className={css.rowName}>
23
38
  <span className={css.rowNameText}>{skill.name}</span>
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Sources tab: the flat skill list or the grouped view (one card per
3
- * upstream collection with check/sync/follow-delete actions and the
4
- * tri-state switch, plus the uncategorized "personal" card).
2
+ * Sources tab: the flat skill list or the grouped view a project-level
3
+ * three-tier tree (workspaces from workspace.json, each optionally split by
4
+ * .dsh/.agents), one card per upstream collection with check/sync/
5
+ * follow-delete actions and the tri-state switch, plus the uncategorized
6
+ * "personal" card (project skills never count as personal).
5
7
  */
6
8
 
7
9
  import type { JSX } from 'react'
8
10
  import { tt } from '../helpers.ts'
9
- import { filterBySource, groupSwitchView, PRIVATE_SOURCE } from '../grouping.ts'
11
+ import { filterBySource, groupSwitchView, isProjectSource, PRIVATE_SOURCE } from '../grouping.ts'
10
12
  import { SourceStatusBadge } from './SourceStatusBadge.tsx'
11
13
  import { SkillRow } from './SkillRow.tsx'
12
14
  import { DisabledRow } from './DisabledRow.tsx'
@@ -14,6 +16,89 @@ import { GroupSummary } from './GroupSummary.tsx'
14
16
  import type { SkillHubState } from './useSkillHub.ts'
15
17
  import css from './panel.module.css'
16
18
 
19
+ /** 项目级三级树:项目级 → 具体工作区(可折叠、可细分)→ .dsh/.agents。 */
20
+ function ProjectTree(props: { hub: SkillHubState }): JSX.Element | null {
21
+ const { hub } = props
22
+ const { sorted, sourceFilter, origins, collapsedGroups, subdividedProjects, toggleGroupCollapse, toggleSubdivide } = hub
23
+ const projectSkills = filterBySource(sorted, sourceFilter, origins).filter((skill) => isProjectSource(skill.source))
24
+ if (projectSkills.length === 0) return null
25
+ // workspace 缺失(过渡态)时按 source 降级分组,避免空标题。
26
+ const byProject = new Map<string, { title: string; skills: typeof projectSkills }>()
27
+ for (const skill of projectSkills) {
28
+ const key = skill.workspace ?? skill.source
29
+ const entry = byProject.get(key)
30
+ if (entry === undefined) {
31
+ byProject.set(key, {
32
+ title: skill.workspaceTitle ?? skill.workspace ?? tt('groups.project'),
33
+ skills: [skill],
34
+ })
35
+ } else {
36
+ entry.skills.push(skill)
37
+ }
38
+ }
39
+ const topCollapsed = collapsedGroups.has('project')
40
+ return (
41
+ <section className={css.section}>
42
+ <div className={css.groupHead}>
43
+ <button type='button' className={css.disclosure} aria-expanded={!topCollapsed} onClick={() => { toggleGroupCollapse('project') }}>
44
+ <span className={css.chevron + (topCollapsed ? ' ' + css.chevronCollapsed : '')} />
45
+ <span className={css.groupTitle}>
46
+ {tt('groups.project')} · {byProject.size}
47
+ </span>
48
+ </button>
49
+ </div>
50
+ {!topCollapsed ? [...byProject.entries()].map(([key, proj]) => {
51
+ const projKey = 'project:' + key
52
+ const projCollapsed = collapsedGroups.has(projKey)
53
+ const subdivided = subdividedProjects.has(key)
54
+ return (
55
+ <div key={projKey} className={css.projectNest}>
56
+ <div className={css.groupHead}>
57
+ <button type='button' className={css.disclosure} aria-expanded={!projCollapsed} onClick={() => { toggleGroupCollapse(projKey) }}>
58
+ <span className={css.chevron + (projCollapsed ? ' ' + css.chevronCollapsed : '')} />
59
+ <span className={css.groupTitle}>
60
+ {proj.title} · {proj.skills.length}
61
+ <GroupSummary members={proj.skills.map((skill) => skill.name)} hub={hub} />
62
+ </span>
63
+ </button>
64
+ <span className={css.groupOps}>
65
+ <button type='button' className={css.opBtn} onClick={(event) => { event.stopPropagation(); toggleSubdivide(key) }}>
66
+ {subdivided ? tt('groups.merge') : tt('groups.subdivide')}
67
+ </button>
68
+ </span>
69
+ </div>
70
+ {!projCollapsed ? (
71
+ subdivided ? (
72
+ <div className={css.projectNest}>
73
+ {(['project-dsh', 'project-agents'] as const).map((source) => {
74
+ const list = proj.skills.filter((skill) => skill.source === source)
75
+ if (list.length === 0) return null
76
+ const srcKey = projKey + ':' + source
77
+ const srcCollapsed = collapsedGroups.has(srcKey)
78
+ return (
79
+ <div key={srcKey} className={css.projectNest}>
80
+ <div className={css.groupHead}>
81
+ <button type='button' className={css.disclosure} aria-expanded={!srcCollapsed} onClick={() => { toggleGroupCollapse(srcKey) }}>
82
+ <span className={css.chevron + (srcCollapsed ? ' ' + css.chevronCollapsed : '')} />
83
+ <span className={css.groupTitle}>
84
+ {tt(('badge.source.' + source) as 'badge.source.project-dsh' | 'badge.source.project-agents')} · {list.length}
85
+ </span>
86
+ </button>
87
+ </div>
88
+ {!srcCollapsed ? list.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />) : null}
89
+ </div>
90
+ )
91
+ })}
92
+ </div>
93
+ ) : proj.skills.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)
94
+ ) : null}
95
+ </div>
96
+ )
97
+ }) : null}
98
+ </section>
99
+ )
100
+ }
101
+
17
102
  export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
18
103
  const { hub } = props
19
104
  const { catalog, groupsState, skillView, sourceFilter, origins, sorted, normalized, collapsedGroups, viewNames, sourceCheck, actionNames, checkingSource, syncingSource, batchBusy, busyNames, toggleGroupCollapse, checkSources, requestSync, requestDelete, toggleGroup, enableDisabled } = hub
@@ -24,6 +109,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
24
109
 
25
110
  return (
26
111
  <>
112
+ <ProjectTree hub={hub} />
27
113
  {groupsState !== null && groupsState.collections.length === 0 ? <div className={css.empty}>{tt('groups.noCollections')}</div> : null}
28
114
  {groupsState?.collections.map((collection) => {
29
115
  const skills = filterBySource(sorted, sourceFilter, origins).filter((skill) => collection.skillNames.includes(skill.name))
@@ -81,7 +167,8 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
81
167
  )
82
168
  })}
83
169
  {(() => {
84
- const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined)
170
+ // 「个人」组:无来源记录 非项目技能(项目技能归项目树)。
171
+ const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
85
172
  if (uncategorized.length === 0) return null
86
173
  const collapsed = collapsedGroups.has('uncategorized-source')
87
174
  return (
@@ -2,7 +2,7 @@
2
2
  cards, hairline separators, restrained accents. Theme-agnostic: all
3
3
  surfaces are translucent greys + currentColor, so light/dark both work. */
4
4
 
5
- .panel { padding: 28px 20px 44px; display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; color-scheme: light dark; /* Dot colors mirror src/client/api-config-scope.ts (DEFAULT_DOT_MODEL_COLOR / DEFAULT_DOT_USER_COLOR) — CSS cannot import TS, so keep the two in sync. */ --hub-model: #2f81f7; --hub-user: #3fb950; }
5
+ .panel { padding: 28px 20px 44px; display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; color-scheme: light dark; /* Dot colors mirror src/client/SkillHubSettingsCard.tsx (DEFAULT_DOT_MODEL_COLOR / DEFAULT_DOT_USER_COLOR) — CSS cannot import TS, so keep the two in sync. */ --hub-model: #2f81f7; --hub-user: #3fb950; }
6
6
  .header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
7
7
  .title { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
8
8
  .hint { font-size: 12px; opacity: .5; }
@@ -34,6 +34,8 @@
34
34
  /* Rows: hairline separators inside a card, no outer borders. */
35
35
  .row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: transparent; cursor: pointer; transition: background .12s ease; }
36
36
  .row:hover { background: rgba(128,128,128,.08); }
37
+ /* Keyboard focus ring for the div-as-button skill row (theme-agnostic). */
38
+ .row:focus-visible { outline: 2px solid var(--hub-model); outline-offset: -2px; }
37
39
  .rowStatic { cursor: default; }
38
40
  .rowStatic:hover { background: transparent; }
39
41
  .row + .row { border-top: 0.5px solid rgba(128,128,128,.13); }
@@ -217,3 +219,9 @@
217
219
  .sectionTitleFill { flex: 1; }
218
220
  .dialogSelect { width: 100%; margin-bottom: 12px; }
219
221
  .dialogRow { display: flex; align-items: center; gap: 8px; }
222
+ /* Workspace (project path) picker in the subbar. */
223
+ .workspaceBox { display: inline-flex; align-items: center; gap: 6px; }
224
+ .workspaceInput { width: 200px; padding: 6px 10px; font-size: 12px; }
225
+ /* Project-level three-tier tree: outer card (the .section), inner levels are
226
+ flat — indent + a hairline guide only, no nested card surfaces. */
227
+ .projectNest { display: flex; flex-direction: column; margin: 2px 0 0 14px; border-left: 1px solid rgba(128,128,128,.14); }
@@ -26,7 +26,7 @@ import type {
26
26
  } from '../../protocol.ts'
27
27
  import type { SkillHubApi } from '../api.ts'
28
28
  import { errorMessage, tt } from '../helpers.ts'
29
- import { conflictsOnClose, PRIVATE_SOURCE, sortSkills, type SortKey, type GroupSwitchState } from '../grouping.ts'
29
+ import { conflictsOnClose, isProjectSource, PRIVATE_SOURCE, sortSkills, type SortKey, type GroupSwitchState } from '../grouping.ts'
30
30
  import type { BranchChoiceState, ConfirmDialogState, ConflictDialogState, MarketSyncDialogState } from './dialogs.tsx'
31
31
 
32
32
  /** Catalog poll interval while the panel is mounted (the provider watcher feeds this). */
@@ -98,6 +98,8 @@ export function useSkillHub(api: SkillHubApi) {
98
98
  const [repoImporting, setRepoImporting] = useState(false)
99
99
  const [repoResult, setRepoResult] = useState<RepoImportResponse | null>(null)
100
100
  const [search, setSearch] = useState('')
101
+ /** 工作区(项目)路径;空 = 只看用户级技能。 */
102
+ const [workspace, setWorkspace] = useState('')
101
103
  const [detail, setDetail] = useState<SkillDetail | null>(null)
102
104
  const [detailLoading, setDetailLoading] = useState(false)
103
105
  const [busyNames, setBusyNames] = useState<ReadonlySet<string>>(new Set())
@@ -139,8 +141,10 @@ export function useSkillHub(api: SkillHubApi) {
139
141
  const [newTagName, setNewTagName] = useState('')
140
142
  const [tagBusy, setTagBusy] = useState(false)
141
143
  const [editSearch, setEditSearch] = useState('')
142
- /** 分组视图里收起的分组(key 为 tag:<id> 或 col:<name>)。 */
144
+ /** 分组视图里收起的分组(key 为 tag:<id>、col:<name> 或 project 树键)。 */
143
145
  const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(new Set())
146
+ /** 项目级三级树里已细分(按 .dsh/.agents)的项目键。 */
147
+ const [subdividedProjects, setSubdividedProjects] = useState<ReadonlySet<string>>(new Set())
144
148
  const [showLegend, setShowLegend] = useState(false)
145
149
 
146
150
  const toggleGroupCollapse = useCallback((key: string): void => {
@@ -152,9 +156,18 @@ export function useSkillHub(api: SkillHubApi) {
152
156
  })
153
157
  }, [])
154
158
 
159
+ const toggleSubdivide = useCallback((key: string): void => {
160
+ setSubdividedProjects((previous) => {
161
+ const next = new Set(previous)
162
+ if (next.has(key)) next.delete(key)
163
+ else next.add(key)
164
+ return next
165
+ })
166
+ }, [])
167
+
155
168
  const load = useCallback(async (): Promise<void> => {
156
169
  try {
157
- const next = await api.catalog()
170
+ const next = await api.catalog(workspace !== '' ? { cwd: workspace } : undefined)
158
171
  setCatalog(next)
159
172
  setLoadError(null)
160
173
  } catch (error) {
@@ -162,7 +175,7 @@ export function useSkillHub(api: SkillHubApi) {
162
175
  } finally {
163
176
  setLoading(false)
164
177
  }
165
- }, [api])
178
+ }, [api, workspace])
166
179
 
167
180
  const loadUses = useCallback(async (): Promise<void> => {
168
181
  try {
@@ -246,13 +259,13 @@ export function useSkillHub(api: SkillHubApi) {
246
259
  setDetailLoading(true)
247
260
  setLoadError(null)
248
261
  try {
249
- setDetail(await api.skill(name))
262
+ setDetail(await api.skill(name, workspace !== '' ? { cwd: workspace } : undefined))
250
263
  } catch (error) {
251
264
  setLoadError(errorMessage(error))
252
265
  } finally {
253
266
  setDetailLoading(false)
254
267
  }
255
- }, [api])
268
+ }, [api, workspace])
256
269
 
257
270
  const toggle = useCallback(async (skill: CatalogSkill, enabled: boolean): Promise<void> => {
258
271
  setBusyNames((previous) => new Set(previous).add(skill.name))
@@ -765,7 +778,8 @@ export function useSkillHub(api: SkillHubApi) {
765
778
  const sourceOptions = useMemo(() => {
766
779
  const skills = catalog?.skills ?? []
767
780
  const repos = [...new Set(skills.map((skill) => origins[skill.name]).filter((repo): repo is string => repo !== undefined))].sort()
768
- const hasPrivate = skills.some((skill) => origins[skill.name] === undefined)
781
+ // 项目级技能(有 workspace 归属)不算「个人」。
782
+ const hasPrivate = skills.some((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
769
783
  return [...repos, ...(hasPrivate ? [PRIVATE_SOURCE] : [])]
770
784
  }, [catalog, origins])
771
785
  const filtered = useMemo(() => (catalog?.skills ?? []).filter((skill) =>
@@ -777,19 +791,19 @@ export function useSkillHub(api: SkillHubApi) {
777
791
  return {
778
792
  // state
779
793
  catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult,
780
- search, detail, detailLoading, busyNames, batchBusy, showForm, formName, formDesc, formRoot, formBusy, formMessage,
794
+ search, workspace, detail, detailLoading, busyNames, batchBusy, showForm, formName, formDesc, formRoot, formBusy, formMessage,
781
795
  uses, hubConfig, tab, skillView, sourceFilter, sortKey, marketState, marketCheck, branchChoice, branchBusy,
782
796
  marketSyncDialog, syncingMarket, syncBusy, newSourceName, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource,
783
797
  conflictDialog, confirmDialog, deleteSkillDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
784
- editSearch, collapsedGroups, showLegend,
798
+ editSearch, collapsedGroups, subdividedProjects, showLegend,
785
799
  // derived
786
800
  actionNames, viewNames, normalized, origins, sourceOptions, filtered, sorted,
787
801
  // actions + setters
788
- setLoadError, setSuccessBanner, setSearch, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage,
802
+ setLoadError, setSuccessBanner, setSearch, setWorkspace, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage,
789
803
  setRepoSelected, setTab, setSkillView,
790
804
  setSourceFilter, setSortKey, setBranchChoice, setMarketSyncDialog, setNewSourceName, setConflictDialog, setConfirmDialog,
791
805
  setDeleteSkillDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend,
792
- toggleGroupCollapse, checkUpdate, loadMarket, openDetail, toggle, enableDisabled, batchToggleNames, toggleGroup, resolveConflict,
806
+ toggleGroupCollapse, toggleSubdivide, checkUpdate, loadMarket, openDetail, toggle, enableDisabled, batchToggleNames, toggleGroup, resolveConflict,
793
807
  runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, requestDeleteSkill, runDeleteSkill, createTag,
794
808
  deleteTag, saveTag, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo,
795
809
  checkMarket, syncMarketSource, confirmMarketSync, updateAll, create,
@@ -8,17 +8,18 @@
8
8
 
9
9
  import { useState, type ReactElement } from 'react'
10
10
  import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import type { HubKey } from './locales.ts'
11
12
  import type { CardShell } from './settings-form.ts'
12
13
  import css from './settings-card.module.css'
13
14
 
14
15
  /** Card-level chrome props. */
15
16
  export interface PluginSettingsCardProps {
16
- /** Locale translator for the owning plugin's namespace. */
17
- t: (key: string) => string
17
+ /** Locale translator for the owning plugin's namespace (its own key domain). */
18
+ t: (key: HubKey) => string
18
19
  /** Locale key of the card title. */
19
- titleKey: string
20
+ titleKey: HubKey
20
21
  /** Locale key of the card description. */
21
- descriptionKey: string
22
+ descriptionKey: HubKey
22
23
  /** The form shell state. */
23
24
  state: CardShell
24
25
  onSave: () => void
package/src/index.ts CHANGED
@@ -9,12 +9,14 @@
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
11
  import type { SkillProviderControl } from '@deepseek-ai/dsh-skill'
12
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
12
13
  import z from 'schemastery'
13
14
  import type {} from '@deepseek-ai/dsh-host-webserver'
14
15
  import type {} from '@deepseek-ai/dsh-skill'
15
16
  import type {} from '@deepseek-ai/dsh-system-prompt'
16
17
  import type {} from '@deepseek-ai/dsh-session-query'
17
- import { HUB_CONFIG_DEFAULTS, resolveHubConfig, type HubConfig } from './protocol.ts'
18
+ import type {} from '@deepseek-ai/dsh-settings'
19
+ import { HUB_CONFIG_DEFAULTS, HEX_COLOR_RE, type HubConfig, type HubSettingsValue } from './protocol.ts'
18
20
  import { SkillHubProvider } from './provider.ts'
19
21
  import { makeRoutes } from './routes.ts'
20
22
  import { createSkillStatsReader, type SkillStatsReader } from './stats.ts'
@@ -24,7 +26,7 @@ import { SkillHubStore } from './store.ts'
24
26
  export const name = 'skill-hub'
25
27
 
26
28
  /** Services required before the skill-hub surfaces can mount. */
27
- export const inject = ['webServer', 'skills', 'systemPrompt']
29
+ export const inject = ['webServer', 'skills', 'systemPrompt', 'settings']
28
30
 
29
31
  /** Plugin config, validated by the same-named schemastery schema. */
30
32
  export interface Config {
@@ -48,6 +50,26 @@ export const Config: z<Config> = z.object({
48
50
  showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
49
51
  })
50
52
 
53
+ /**
54
+ * Settings namespace hosting the hub's runtime config. Since dsh rc.7 the
55
+ * host serves every registered settings namespace to the web client (the
56
+ * dsh-host-apiproxy allowlist is gone), so the browser card and the settings
57
+ * page edit this namespace through the official settings transport, and the
58
+ * plugin consumes the same resolved value — one source of truth.
59
+ */
60
+ export const CONFIG_NAMESPACE = settingsNamespace('dsh-skill-hub')
61
+
62
+ /** Schema of the hub's settings namespace: the card's fields (booleans + optional dot colors). */
63
+ export const HubSettingsSchema: z<HubSettingsValue> = z.object({
64
+ enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
65
+ announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent),
66
+ showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
67
+ showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
68
+ showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
69
+ dotModelColor: z.string().pattern(HEX_COLOR_RE),
70
+ dotUserColor: z.string().pattern(HEX_COLOR_RE),
71
+ })
72
+
51
73
  /** Order of the announcement section within the tool-guidance band. */
52
74
  const SECTION_ORDER = 152
53
75
 
@@ -59,23 +81,22 @@ export const SKILL_HUB_GUIDANCE = [
59
81
 
60
82
  /**
61
83
  * Mount the skill hub routes and announcement.
62
- * @param ctx - host plugin context carrying webServer/skills/systemPrompt.
84
+ * @param ctx - host plugin context carrying webServer/skills/systemPrompt/settings.
63
85
  * @param config - resolved plugin config (schema defaults applied by the loader).
64
86
  */
65
87
  export function apply(ctx: Context, config?: Config): void {
66
- // The hub owns its runtime configuration: the cordis composition entry
67
- // seeds the defaults, and the web settings card persists edits into the
68
- // sidecar store (~/.dsh/dsh-skill-hub.json). The host's settings service
69
- // deliberately refuses to expose third-party namespaces to the web client
70
- // (dsh-host-apiproxy's allowlist), so the card talks to /api/skill-hub/config
71
- // instead of the settings transport.
88
+ // The hub's runtime configuration lives in dsh's own settings service:
89
+ // since rc.7 the host serves every registered settings namespace to the
90
+ // web client (dsh-host-apiproxy's allowlist is gone), so the browser card
91
+ // and the config route edit this namespace through the official settings
92
+ // transport, and the host consumes the very same resolved value — one
93
+ // source of truth. The cordis composition entry seeds the base layer; the
94
+ // sidecar config survives only as a one-time migration source below.
72
95
  const base = config ?? {}
73
- // Saved sidecar overrides win over the cordis entry; the shared resolver
74
- // fills the rest from HUB_CONFIG_DEFAULTS (single source, see protocol.ts).
75
- let current: () => HubConfig = () => resolveHubConfig({}, base)
76
- // The raw saved config layer (fields the user explicitly overrode); the
77
- // config route reports it so the web card can mark overridden fields.
78
- let savedState: Partial<HubConfig> = {}
96
+ const settingsScope = ctx.settings.register(CONFIG_NAMESPACE, HubSettingsSchema, { base })
97
+ // The effective resolved config, read live from the settings namespace
98
+ // (schema defaults, then the composition base, then the user layer).
99
+ const current = (): HubConfig => settingsScope.get()
79
100
 
80
101
  const store = new SkillHubStore()
81
102
  let disposeRoutes: (() => void) | undefined
@@ -90,16 +111,27 @@ export function apply(ctx: Context, config?: Config): void {
90
111
  // stats route's data rather than failing to load.
91
112
  let stats: SkillStatsReader | undefined
92
113
 
114
+ // The raw saved config layer (fields the user explicitly overrode); the
115
+ // config route reports it so callers can mark overridden fields.
116
+ const saved = (): Partial<HubConfig> => {
117
+ const descriptor = ctx.settings.describe().find((entry) => entry.ns === CONFIG_NAMESPACE)
118
+ return (descriptor?.user as Partial<HubConfig> | undefined) ?? {}
119
+ }
120
+
93
121
  // Persist a config patch, re-point the live config, and re-sync every
94
- // surface. Runs inside the config route handler; the response is written
95
- // after sync() has settled the new registration set.
122
+ // surface through the settings transport. A patch value of undefined clears
123
+ // the saved override (the key leaves the user section, so the field
124
+ // re-inherits the base/default) — the old sidecar's reset semantics.
125
+ // Runs inside the config route handler; the watcher below re-syncs the
126
+ // surfaces once the namespace commits.
96
127
  const updateConfig = async (patch: Partial<HubConfig>): Promise<HubConfig> => {
97
- await store.setConfig(patch)
98
- savedState = await store.getConfig()
99
- const next = resolveHubConfig(savedState, base)
100
- current = () => next
101
- sync()
102
- return next
128
+ const user: Record<string, unknown> = { ...saved() }
129
+ for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | undefined]>) {
130
+ if (value === undefined) delete user[key]
131
+ else user[key] = value
132
+ }
133
+ await settingsScope.replace(user)
134
+ return settingsScope.get()
103
135
  }
104
136
 
105
137
  // Register (or drop) every surface to match the current config. Each
@@ -149,7 +181,7 @@ export function apply(ctx: Context, config?: Config): void {
149
181
  invalidate: () => { providerControl?.invalidate() },
150
182
  stats,
151
183
  config: current,
152
- saved: () => savedState,
184
+ saved,
153
185
  updateConfig,
154
186
  }).map((route) => ctx.webServer.register(route))
155
187
  return () => {
@@ -160,14 +192,29 @@ export function apply(ctx: Context, config?: Config): void {
160
192
  )
161
193
  }
162
194
 
163
- // Initial registration from the composition entry, then adopt the saved
164
- // sidecar config once the store has read it (re-sync re-points surfaces).
195
+ // Initial registration from the composition entry, then re-sync whenever
196
+ // the settings namespace commits (any writer the card, the config route,
197
+ // or the Host document editor).
165
198
  sync()
166
- void store.getConfig().then((saved) => {
167
- savedState = saved
168
- current = () => resolveHubConfig(saved, base)
169
- sync()
170
- })
199
+ ctx.effect(
200
+ () => settingsScope.watch(() => { sync() }),
201
+ 'dsh-skill-hub: settings config watch',
202
+ )
203
+
204
+ // One-time migration: an install upgraded from the sidecar-configured
205
+ // build seeds the settings namespace from the saved sidecar config when the
206
+ // namespace has no user section yet. Later edits live only in the settings
207
+ // document; the sidecar keeps its (now-stale) copy untouched.
208
+ void (async () => {
209
+ const legacy = await store.getConfig()
210
+ if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
211
+ try {
212
+ await settingsScope.update(legacy as Record<string, unknown>)
213
+ } catch (error) {
214
+ ctx.logger.warn('[dsh-skill-hub] sidecar config migration into the settings namespace failed', error)
215
+ }
216
+ }
217
+ })()
171
218
 
172
219
  // Optional trigger statistics: while a session-query service exists, wire a
173
220
  // cached invocation-count reader into the stats route. Re-running sync()
package/src/protocol.ts CHANGED
@@ -56,10 +56,16 @@ export interface CatalogSkill {
56
56
  provider: string
57
57
  /** Whether the hub may toggle this skill (user-level filesystem skills only). */
58
58
  writable: boolean
59
+ /** 技能来源标识(user-dsh/user-agents/project-dsh/project-agents/...)。 */
60
+ source: string
59
61
  /** SKILL.md creation time (epoch ms); used for "added" sorting. Absent when unknown. */
60
62
  addedAt?: number
61
63
  /** SKILL.md last-modified time (epoch ms); used for "updated" display. Absent when unknown. */
62
64
  updatedAt?: number
65
+ /** 项目技能的所属工作区路径(仅 project-dsh/project-agents 来源携带)。 */
66
+ workspace?: string
67
+ /** 工作区显示标题(来自 workspace.json;无则回退为路径)。 */
68
+ workspaceTitle?: string
63
69
  }
64
70
 
65
71
  /** One disabled skill tracked by the hub sidecar (SKILL.md renamed away). */
@@ -213,6 +219,24 @@ export interface HubConfig {
213
219
  showGroupSummary?: boolean
214
220
  }
215
221
 
222
+ /**
223
+ * The resolved shape of the hub's settings namespace (schema defaults, then
224
+ * the composition base, then the user layer). Kept as a type alias so the
225
+ * browser-side settings scope snapshot is index-compatible with the card
226
+ * form's record shape.
227
+ */
228
+ export type HubSettingsValue = {
229
+ enabled: boolean
230
+ announceToAgent: boolean
231
+ showUseCount: boolean
232
+ showUseTime: boolean
233
+ showGroupSummary: boolean
234
+ /** Model-invocable dot color (#rrggbb); absent means the panel default. */
235
+ dotModelColor?: string
236
+ /** User-invocable dot color (#rrggbb); absent means the panel default. */
237
+ dotUserColor?: string
238
+ }
239
+
216
240
  /**
217
241
  * Hub config defaults — the single source every layer reads: the cordis
218
242
  * schema (index.ts), the host's saved-override merge, and the routes'
@@ -141,6 +141,61 @@ describe('skill-hub routes', () => {
141
141
  expect(captured).toBe('/tmp/project')
142
142
  })
143
143
 
144
+ it('merges every known workspace into the default catalog with workspace fields', async () => {
145
+ // workspace.json 声明两个工作区;每个工作区的 snapshot 返回其项目技能 + 用户级技能。
146
+ await mkdir(join(home, 'storages'), { recursive: true })
147
+ await writeFile(join(home, 'storages', 'workspace.json'), JSON.stringify({
148
+ tables: {
149
+ workspaces: {
150
+ a: { title: 'Alpha', path: '/ws/a' },
151
+ b: { title: 'Beta', path: '/ws/b' },
152
+ },
153
+ },
154
+ }), 'utf8')
155
+ skills.snapshot = async (options?: { cwd?: string }) => {
156
+ const user = summary({ name: 'shared-user' })
157
+ if (options?.cwd === '/ws/a') {
158
+ return { skills: [user, summary({ name: 'ws-a-skill', source: 'project-dsh', provider: 'skill-hub' })], complete: true }
159
+ }
160
+ if (options?.cwd === '/ws/b') {
161
+ return { skills: [user, summary({ name: 'ws-b-skill', source: 'project-agents', provider: 'skill-hub' })], complete: false }
162
+ }
163
+ return { skills: [user], complete: true }
164
+ }
165
+ const res = new FakeResponse()
166
+ await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog), res as never)
167
+ expect(res.status).toBe(200)
168
+ const body = res.json() as CatalogResponse
169
+ expect(body.complete).toBe(false) // 任一工作区不完整则整体不完整
170
+ const byName = new Map(body.skills.map((skill) => [skill.name, skill]))
171
+ expect(byName.has('ws-a-skill')).toBe(true)
172
+ expect(byName.get('ws-a-skill')).toMatchObject({ workspace: '/ws/a', workspaceTitle: 'Alpha', writable: false })
173
+ expect(byName.get('ws-b-skill')).toMatchObject({ workspace: '/ws/b', workspaceTitle: 'Beta' })
174
+ // 同名用户级技能只出现一次,且不带 workspace 字段。
175
+ expect(byName.get('shared-user')).toMatchObject({ writable: true })
176
+ expect(byName.get('shared-user')?.workspace).toBeUndefined()
177
+ })
178
+
179
+ it('falls back across workspaces when fetching a project skill detail without cwd', async () => {
180
+ await mkdir(join(home, 'storages'), { recursive: true })
181
+ await writeFile(join(home, 'storages', 'workspace.json'), JSON.stringify({
182
+ tables: { workspaces: { a: { title: 'Alpha', path: '/ws/a' } } },
183
+ }), 'utf8')
184
+ const calls: Array<string | undefined> = []
185
+ skills.get = async (name: string, options?: { cwd?: string }) => {
186
+ calls.push(options?.cwd)
187
+ return name === 'ws-a-skill' && options?.cwd === '/ws/a' ? definition({ name, source: 'project-dsh' }) : undefined
188
+ }
189
+ const res = new FakeResponse()
190
+ await routeFor(SKILL_HUB_API.skill).handler(fakeReq('GET', SKILL_HUB_API.skill + '?name=ws-a-skill'), res as never)
191
+ expect(res.status).toBe(200)
192
+ expect(calls).toEqual(['/ws/a']) // 工作区命中即停,不再回退用户根
193
+ const miss = new FakeResponse()
194
+ await routeFor(SKILL_HUB_API.skill).handler(fakeReq('GET', SKILL_HUB_API.skill + '?name=nope'), miss as never)
195
+ expect(miss.status).toBe(404)
196
+ expect(calls).toEqual(['/ws/a', '/ws/a', undefined]) // 未命中时回退用户根
197
+ })
198
+
144
199
  it('serves skill detail and 404s unknown names', async () => {
145
200
  skills.get = async (name: string) => name === 'demo-skill' ? definition({ path: '/x/demo-skill/SKILL.md' }) : undefined
146
201
  const ok = new FakeResponse()