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.
- package/CONTRIBUTING.md +1 -1
- package/README.md +181 -249
- package/README.zh.md +275 -0
- package/lib/client.js +395 -257
- package/lib/client.js.map +1 -1
- package/lib/index.js +126 -26
- package/lib/types/client/SkillHubSettingsCard.d.ts +23 -17
- package/lib/types/client/api.d.ts +9 -3
- package/lib/types/client/grouping.d.ts +3 -0
- package/lib/types/client/index.d.ts +12 -6
- package/lib/types/client/locales.d.ts +8 -4
- package/lib/types/client/market-catalog.d.ts +1 -1
- package/lib/types/client/panel/MarketView.d.ts +5 -5
- package/lib/types/client/panel/SkillHubPanel.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +5 -3
- package/lib/types/client/panel/useSkillHub.d.ts +4 -0
- package/lib/types/client/settings-card.d.ts +5 -4
- package/lib/types/index.d.ts +12 -1
- package/lib/types/protocol.d.ts +23 -0
- package/lib/types/skillfs.d.ts +1 -1
- package/lib/types/update.d.ts +1 -1
- package/package.json +28 -27
- package/src/client/SkillHubSettingsCard.tsx +25 -14
- package/src/client/api.ts +9 -6
- package/src/client/grouping.test.ts +12 -0
- package/src/client/grouping.ts +10 -1
- package/src/client/index.tsx +25 -19
- package/src/client/locales.ts +16 -8
- package/src/client/market-catalog.ts +1 -1
- package/src/client/panel/MarketView.tsx +77 -64
- package/src/client/panel/SkillHubPanel.tsx +18 -1
- package/src/client/panel/SkillRow.tsx +17 -2
- package/src/client/panel/SourcesView.tsx +92 -5
- package/src/client/panel/panel.module.css +9 -1
- package/src/client/panel/useSkillHub.ts +25 -11
- package/src/client/settings-card.tsx +5 -4
- package/src/index.ts +78 -31
- package/src/protocol.ts +24 -0
- package/src/routes.test.ts +55 -0
- package/src/routes.ts +90 -10
- package/src/skillfs.ts +1 -1
- package/src/update.ts +1 -1
- package/lib/types/client/api-config-scope.d.ts +0 -48
- package/src/client/api-config-scope.test.ts +0 -79
- 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),
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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/
|
|
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> 或
|
|
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
|
-
|
|
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:
|
|
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:
|
|
20
|
+
titleKey: HubKey
|
|
20
21
|
/** Locale key of the card description. */
|
|
21
|
-
descriptionKey:
|
|
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
|
|
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
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
95
|
-
//
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
164
|
-
//
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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'
|
package/src/routes.test.ts
CHANGED
|
@@ -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()
|