dsh-skill-hub 0.3.13 → 0.3.15
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 +6 -4
- package/README.md +9 -5
- package/README.zh.md +9 -5
- package/lib/client.js +55 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +529 -315
- package/lib/types/client/SkillHubSettingsCard.d.ts +5 -3
- package/lib/types/client/icons.d.ts +4 -5
- package/lib/types/client/index.d.ts +13 -12
- package/lib/types/client/locales/market.d.ts +1 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/settings-form.d.ts +9 -3
- package/lib/types/client/slash-dots.d.ts +20 -11
- package/lib/types/index.d.ts +59 -24
- package/lib/types/protocol/api.d.ts +22 -2
- package/lib/types/protocol/config.d.ts +30 -4
- package/lib/types/protocol/market.d.ts +1 -1
- package/lib/types/protocol/repo.d.ts +18 -1
- package/lib/types/protocol.d.ts +3 -3
- package/lib/types/repo/discovery.d.ts +59 -3
- package/lib/types/repo/github-client.d.ts +16 -0
- package/lib/types/repo.d.ts +6 -3
- package/lib/types/routes/helpers.d.ts +8 -1
- package/lib/types/routes/market.d.ts +2 -2
- package/lib/types/routes.d.ts +1 -1
- package/lib/types/store/store.d.ts +4 -0
- package/package.json +31 -41
- package/src/client/SkillHubSettingsCard.tsx +5 -3
- package/src/client/icons.tsx +4 -5
- package/src/client/index.tsx +33 -32
- package/src/client/locales/market.ts +2 -0
- package/src/client/panel/RepoScanCard.tsx +6 -3
- package/src/client/panel/SkillHubPanel.tsx +1 -1
- package/src/client/panel/SourcesView.tsx +1 -1
- package/src/client/panel/hooks/useGroupFlow.ts +1 -8
- package/src/client/settings-form.ts +9 -4
- package/src/client/slash-dots.test.ts +19 -36
- package/src/client/slash-dots.tsx +30 -60
- package/src/index.ts +145 -82
- package/src/protocol/api.ts +24 -2
- package/src/protocol/config.ts +35 -4
- package/src/protocol/market.ts +1 -1
- package/src/protocol/repo.ts +22 -1
- package/src/protocol.ts +3 -3
- package/src/repo/discovery.ts +120 -33
- package/src/repo/github-client.ts +18 -1
- package/src/repo/install.ts +8 -7
- package/src/repo.test.ts +158 -4
- package/src/repo.ts +10 -2
- package/src/routes/config.ts +19 -2
- package/src/routes/helpers.ts +9 -2
- package/src/routes/market.ts +49 -37
- package/src/routes/repo-import.ts +3 -2
- package/src/routes/sources.ts +2 -1
- package/src/routes.test.ts +107 -1
- package/src/routes.ts +21 -3
- package/src/store/migrate.ts +3 -2
- package/src/store/store.ts +7 -2
- package/src/store.test.ts +50 -0
- package/src/update.ts +4 -2
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
* Slash-menu skill dots: puts the invocation-status dot (model-callable blue /
|
|
3
3
|
* user-only green) in front of every skill candidate in the chat `/` menu.
|
|
4
4
|
*
|
|
5
|
-
* Mechanism
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
5
|
+
* Mechanism: hooks the `/skill` source's `candidates` so the hub learns which
|
|
6
|
+
* rows are skill candidates and how each one classifies for model invocation,
|
|
7
|
+
* then injects the dot straight into the rendered option rows. The menu's
|
|
8
|
+
* `icon` slot is unusable for this — `MenuView` narrowed `icon` to an enum
|
|
9
|
+
* ('file' | 'folder' | 'session') and renders it through `ReferenceIcon`, so a
|
|
10
|
+
* custom element never reaches the DOM. Colors come from the same settings
|
|
11
|
+
* (dotModelColor / dotUserColor) and the same `modelInvocable` classification
|
|
12
|
+
* the panel legend uses — so the chat menu and the Settings → 技能 panel stay
|
|
13
|
+
* in sync, and editing the color updates both.
|
|
13
14
|
*
|
|
14
15
|
* The skill source is registered by the core plugin under the `name` "skill"
|
|
15
16
|
* on the `/` trigger; re-registering the same name would throw, so this wraps
|
|
@@ -19,13 +20,12 @@
|
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
22
|
-
import type {
|
|
23
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
23
24
|
// Type-only: pulls the Context merge for ctx.inputTriggers.
|
|
24
25
|
import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
25
26
|
// Type-only: pulls the connection/reset event.
|
|
26
27
|
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
|
27
|
-
import type {
|
|
28
|
-
import { createElement } from 'react'
|
|
28
|
+
import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
29
29
|
import type { HubSettingsValue } from '../protocol.ts'
|
|
30
30
|
import type { SkillHubApi } from './api.ts'
|
|
31
31
|
import { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR } from './panel/format.ts'
|
|
@@ -92,37 +92,11 @@ async function modelInvocableMap(api: SkillHubApi): Promise<Map<string, boolean>
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* @param
|
|
98
|
-
* @
|
|
99
|
-
|
|
100
|
-
function dotIcon(color: string): InputTriggerCandidate['icon'] {
|
|
101
|
-
return createElement('span', {
|
|
102
|
-
'aria-hidden': true,
|
|
103
|
-
style: {
|
|
104
|
-
display: 'inline-block',
|
|
105
|
-
width: 6,
|
|
106
|
-
height: 6,
|
|
107
|
-
borderRadius: 3,
|
|
108
|
-
background: color,
|
|
109
|
-
flex: 'none',
|
|
110
|
-
},
|
|
111
|
-
}) as unknown as InputTriggerCandidate['icon']
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Wrap the core skill source so every menu row carries the invocation dot.
|
|
116
|
-
* Exported for unit tests; production wiring goes through setupSkillSlashDots.
|
|
117
|
-
* @param source - the registered `/skill` source.
|
|
118
|
-
* @param api - hub browser API for the modelInvocable lookup.
|
|
119
|
-
* @param scope - hub settings scope for the dot colors.
|
|
120
|
-
* @returns a disposer restoring the original candidates.
|
|
121
|
-
*/
|
|
122
|
-
/**
|
|
123
|
-
* DOM 兜底:在 alpha.2 新版 MenuView(icon 仅枚举)下通过直接操作
|
|
124
|
-
* 已渲染的 `[role="option"]` 列表注入彩色点,绕过 `icon` 限制。
|
|
125
|
-
* 旧版仍走 `icon` 注入,此处仅为新版。
|
|
95
|
+
* 通过直接操作已渲染的 `[role="option"]` 列表注入彩色点,
|
|
96
|
+
* 绕过 MenuView 对 `icon` 的枚举限制。
|
|
97
|
+
* @param modelByName - 技能名 → 模型可调用性;不在表内的候选不注点。
|
|
98
|
+
* @param modelColor - 模型可调用技能的点色。
|
|
99
|
+
* @param userColor - 仅用户可调用技能的点色。
|
|
126
100
|
*/
|
|
127
101
|
function injectDotsViaDOM(modelByName: Map<string, boolean>, modelColor: string, userColor: string): void {
|
|
128
102
|
if (typeof document === 'undefined' || typeof requestAnimationFrame === 'undefined') return
|
|
@@ -145,7 +119,7 @@ function injectDotsViaDOM(modelByName: Map<string, boolean>, modelColor: string,
|
|
|
145
119
|
// 仅对技能生效:非技能候选(/command、@file 等)不在 catalog,不注点
|
|
146
120
|
if (!modelByName.has(name)) continue
|
|
147
121
|
const color = (modelByName.get(name) ?? true) ? modelColor : userColor
|
|
148
|
-
//
|
|
122
|
+
// 布局对齐菜单行原有的 icon 槽:16×16 容器居中 6px 点
|
|
149
123
|
const wrapper = document.createElement('span')
|
|
150
124
|
wrapper.setAttribute('data-skill-dot', '')
|
|
151
125
|
wrapper.setAttribute('aria-hidden', 'true')
|
|
@@ -163,37 +137,33 @@ function injectDotsViaDOM(modelByName: Map<string, boolean>, modelColor: string,
|
|
|
163
137
|
dot.style.background = color
|
|
164
138
|
dot.style.flex = 'none'
|
|
165
139
|
wrapper.appendChild(dot)
|
|
166
|
-
//
|
|
140
|
+
// 直接插在名称前,与菜单行原有 icon 槽的位置一致
|
|
167
141
|
nameEl.parentElement?.insertBefore(wrapper, nameEl)
|
|
168
142
|
}
|
|
169
143
|
}
|
|
170
144
|
requestAnimationFrame(() => setTimeout(run, 0))
|
|
171
145
|
}
|
|
172
146
|
|
|
173
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Wrap the core skill source so every menu row carries the invocation dot.
|
|
149
|
+
* Exported for unit tests; production wiring goes through setupSkillSlashDots.
|
|
150
|
+
* @param source - the registered `/skill` source.
|
|
151
|
+
* @param api - hub browser API for the modelInvocable lookup.
|
|
152
|
+
* @param scope - hub settings scope for the dot colors.
|
|
153
|
+
* @returns a disposer restoring the original candidates.
|
|
154
|
+
*/
|
|
155
|
+
export function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, scope: ConfigForm<HubSettingsValue>): () => void {
|
|
174
156
|
const original = source.candidates
|
|
175
157
|
source.candidates = async (session, req) => {
|
|
176
158
|
const items = await original(session, req)
|
|
177
159
|
if (req.signal.aborted) return items
|
|
178
|
-
// Dual-compat: 0.1.2-alpha.2 的 MenuView 将 icon 收窄为 'file'|'folder'|'session'
|
|
179
|
-
// 并通过 ReferenceIcon 渲染,旧版的自定义 ReactElement 点已无法展示。
|
|
180
|
-
// 以 `drilled` 是否存在探测新版(alpha.2 必有,rc 无)。
|
|
181
|
-
const isNewHost = req !== null && typeof req === 'object' && 'drilled' in (req as unknown as Record<string, unknown>)
|
|
182
160
|
const modelByName = await modelInvocableMap(api)
|
|
183
161
|
if (req.signal.aborted) return items
|
|
184
162
|
const snapshot = scope.getSnapshot()
|
|
185
163
|
const modelColor = snapshot.value?.dotModelColor ?? DEFAULT_DOT_MODEL_COLOR
|
|
186
164
|
const userColor = snapshot.value?.dotUserColor ?? DEFAULT_DOT_USER_COLOR
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
injectDotsViaDOM(modelByName, modelColor, userColor)
|
|
190
|
-
return items
|
|
191
|
-
}
|
|
192
|
-
// 旧版:保持原有 icon 注入
|
|
193
|
-
return items.map((item) => ({
|
|
194
|
-
...item,
|
|
195
|
-
icon: dotIcon((modelByName.get(item.name) ?? true) ? modelColor : userColor),
|
|
196
|
-
}))
|
|
165
|
+
injectDotsViaDOM(modelByName, modelColor, userColor)
|
|
166
|
+
return items
|
|
197
167
|
}
|
|
198
168
|
return () => {
|
|
199
169
|
source.candidates = original
|
|
@@ -214,7 +184,7 @@ export function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, sc
|
|
|
214
184
|
export function setupSkillSlashDots(
|
|
215
185
|
ctx: ClientContext,
|
|
216
186
|
api: SkillHubApi,
|
|
217
|
-
scope:
|
|
187
|
+
scope: ConfigForm<HubSettingsValue>,
|
|
218
188
|
): () => void {
|
|
219
189
|
const inputTriggers = ctx.get('inputTriggers')
|
|
220
190
|
if (inputTriggers === undefined) return () => {}
|
package/src/index.ts
CHANGED
|
@@ -7,16 +7,19 @@
|
|
|
7
7
|
* source changes.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
import type { Context, Volatile } from '@deepseek-ai/cordis'
|
|
11
11
|
import type { SkillProviderControl } from '@deepseek-ai/dsh-skill'
|
|
12
|
-
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
13
|
-
|
|
12
|
+
import type { SettingsForms, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
|
|
13
|
+
// dsh's own fork, NOT the plain `schemastery` package: `.volatile()` (and the
|
|
14
|
+
// loader's `fiber.runtime.Config` handling) only exist here. Every official
|
|
15
|
+
// plugin imports it under this name.
|
|
16
|
+
import z from '@deepseek-ai/schemastery'
|
|
14
17
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
15
18
|
import type {} from '@deepseek-ai/dsh-skill'
|
|
16
19
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
17
20
|
import type {} from '@deepseek-ai/dsh-session-query'
|
|
18
21
|
import type {} from '@deepseek-ai/dsh-settings'
|
|
19
|
-
import { HUB_CONFIG_DEFAULTS, HEX_COLOR_RE,
|
|
22
|
+
import { HUB_CONFIG_DEFAULTS, HUB_ENTRY_ID, HEX_COLOR_RE, resolveHubConfig, type HubConfig } from './protocol.ts'
|
|
20
23
|
import { SkillHubProvider } from './provider.ts'
|
|
21
24
|
import { makeRoutes } from './routes.ts'
|
|
22
25
|
import { createSkillStatsReader, asPersistenceSeam, type SessionPersistenceLike, type SessionQueryLike, type SkillStatsReader } from './stats.ts'
|
|
@@ -30,87 +33,140 @@ import { homedir } from 'node:os'
|
|
|
30
33
|
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
31
34
|
export const name = 'skill-hub'
|
|
32
35
|
|
|
33
|
-
/** Services required before the skill-hub surfaces can mount. */
|
|
34
|
-
export const inject = ['webServer', 'skills', 'systemPrompt'
|
|
36
|
+
/** Services required before the skill-hub surfaces can mount. `settings` is deliberately NOT here: the plugin reads its own volatile config refs and only reaches for the settings service when it is present (issue #11). */
|
|
37
|
+
export const inject = ['webServer', 'skills', 'systemPrompt']
|
|
35
38
|
|
|
36
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Plugin config, validated by the same-named schemastery schema.
|
|
41
|
+
*
|
|
42
|
+
* dsh 0.1.7 replaced the "plugin registers a settings namespace" model with
|
|
43
|
+
* "the Loader entry's Config IS the settings namespace", so this one schema is
|
|
44
|
+
* both the composition config and the settings page. Every field is volatile:
|
|
45
|
+
* the Loader re-resolves volatile values in place and re-enters apply()
|
|
46
|
+
* instead of rebuilding the fiber, and only volatile fields are writable
|
|
47
|
+
* through the settings transport.
|
|
48
|
+
*/
|
|
37
49
|
export interface Config {
|
|
38
50
|
/** When true (default), a system-prompt section announces the hub to every agent. */
|
|
39
|
-
announceToAgent
|
|
51
|
+
announceToAgent: Volatile<boolean>
|
|
40
52
|
/** Master switch for the plugin (routes, prompt section). */
|
|
41
|
-
enabled
|
|
53
|
+
enabled: Volatile<boolean>
|
|
42
54
|
/** Show per-skill invocation count chip. Default true. */
|
|
43
|
-
showUseCount
|
|
55
|
+
showUseCount: Volatile<boolean>
|
|
44
56
|
/** Show per-skill last-used relative time. Default true. */
|
|
45
|
-
showUseTime
|
|
57
|
+
showUseTime: Volatile<boolean>
|
|
46
58
|
/** Show group-header usage summaries (count + last used). Default true. */
|
|
47
|
-
showGroupSummary
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
|
|
59
|
+
showGroupSummary: Volatile<boolean>
|
|
60
|
+
/** 模型可调圆点颜色(#rrggbb);缺省用面板默认色。 */
|
|
61
|
+
dotModelColor: Volatile<string | undefined>
|
|
62
|
+
/** 用户可调圆点颜色(#rrggbb);缺省用面板默认色。 */
|
|
63
|
+
dotUserColor: Volatile<string | undefined>
|
|
64
|
+
/** GitHub token;`role('secret')` 让 settings 层统一脱敏,缺省为匿名。 */
|
|
65
|
+
githubToken: Volatile<string | undefined>
|
|
66
|
+
/** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。 */
|
|
67
|
+
statsWindowDays: Volatile<number>
|
|
68
|
+
/** 自动统计扫描间隔(分钟,最小 1)。 */
|
|
69
|
+
statsScanMinutes: Volatile<number>
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The durable field schemas, plain (non-volatile) so the same definitions can
|
|
74
|
+
* back both the live view below and the wire form the settings page renders
|
|
75
|
+
* from `.toJSON()`. `description` is what the auto-generated page shows with
|
|
76
|
+
* each row; `role('secret')` makes the settings layer redact the value on
|
|
77
|
+
* every wire read.
|
|
78
|
+
*/
|
|
79
|
+
const ConfigFields = {
|
|
80
|
+
enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled).description('关闭后技能中枢的路由、入口与公告全部下线。'),
|
|
81
|
+
announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent).description('在系统提示中加入本插件说明,用户提到技能管理时 Agent 知道如何协作。'),
|
|
82
|
+
dotModelColor: z.string().pattern(HEX_COLOR_RE).description('技能行与聊天「/」菜单中「模型可调」圆点的颜色(#rrggbb)。'),
|
|
83
|
+
dotUserColor: z.string().pattern(HEX_COLOR_RE).description('技能行与聊天「/」菜单中「仅用户可调」圆点的颜色(#rrggbb)。'),
|
|
84
|
+
showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount).description('在技能名旁显示调用次数。'),
|
|
85
|
+
showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime).description('在技能名行显示最近调用时间。'),
|
|
86
|
+
showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary).description('在分组标题后汇总调用次数与最近调用时间。'),
|
|
87
|
+
statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays).description('只统计最近 N 天的使用次数;0 = 全部历史。'),
|
|
88
|
+
statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes).description('后台扫描会话日志的间隔(分钟,最小 1)。'),
|
|
89
|
+
githubToken: z.string().role('secret').description('市场/来源走 GitHub API:匿名每小时 60 次,填 token 后 5000 次。留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。'),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The live plugin config the settings page edits. Field order here is the row
|
|
94
|
+
* order the auto-generated page renders.
|
|
95
|
+
*/
|
|
96
|
+
export const Config = z.object({
|
|
97
|
+
enabled: ConfigFields.enabled.volatile(),
|
|
98
|
+
announceToAgent: ConfigFields.announceToAgent.volatile(),
|
|
99
|
+
dotModelColor: ConfigFields.dotModelColor.volatile(),
|
|
100
|
+
dotUserColor: ConfigFields.dotUserColor.volatile(),
|
|
101
|
+
showUseCount: ConfigFields.showUseCount.volatile(),
|
|
102
|
+
showUseTime: ConfigFields.showUseTime.volatile(),
|
|
103
|
+
showGroupSummary: ConfigFields.showGroupSummary.volatile(),
|
|
104
|
+
statsWindowDays: ConfigFields.statsWindowDays.volatile(),
|
|
105
|
+
statsScanMinutes: ConfigFields.statsScanMinutes.volatile(),
|
|
106
|
+
githubToken: ConfigFields.githubToken.volatile(),
|
|
62
107
|
})
|
|
63
108
|
|
|
64
109
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* page edit this namespace through the official settings transport, and the
|
|
69
|
-
* plugin consumes the same resolved value — one source of truth.
|
|
110
|
+
* The settings namespace this plugin's config lives under, narrowed to the
|
|
111
|
+
* settings package's branded type. The browser half resolves the same form
|
|
112
|
+
* through `ctx.configForms.get(HUB_ENTRY_ID)`.
|
|
70
113
|
*/
|
|
71
|
-
export const
|
|
114
|
+
export const ENTRY_ID = HUB_ENTRY_ID as SettingsNamespace
|
|
72
115
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
enabled
|
|
76
|
-
announceToAgent
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
116
|
+
/** Every config field, in the order the settings page renders them (used to walk the volatile refs). */
|
|
117
|
+
const CONFIG_FIELDS = [
|
|
118
|
+
'enabled',
|
|
119
|
+
'announceToAgent',
|
|
120
|
+
'dotModelColor',
|
|
121
|
+
'dotUserColor',
|
|
122
|
+
'showUseCount',
|
|
123
|
+
'showUseTime',
|
|
124
|
+
'showGroupSummary',
|
|
125
|
+
'statsWindowDays',
|
|
126
|
+
'statsScanMinutes',
|
|
127
|
+
'githubToken',
|
|
128
|
+
] as const
|
|
86
129
|
|
|
87
130
|
/** Order of the announcement section within the tool-guidance band. */
|
|
88
131
|
const SECTION_ORDER = 152
|
|
89
132
|
|
|
90
133
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
91
134
|
export const SKILL_HUB_GUIDANCE = [
|
|
92
|
-
'本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置
|
|
93
|
-
'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page;
|
|
135
|
+
'本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由仅回环可访问。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。',
|
|
136
|
+
'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page; the plugin\'s configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with "check all" and "update all" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; routes are loopback-only. When the user mentions "skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill", this plugin is what they mean — collaborate accordingly.'
|
|
94
137
|
].join('\n\n')
|
|
95
138
|
|
|
96
139
|
/**
|
|
97
140
|
* Mount the skill hub routes and announcement.
|
|
98
141
|
* @param ctx - host plugin context carrying webServer/skills/systemPrompt/settings.
|
|
99
|
-
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
100
142
|
*/
|
|
101
143
|
export function apply(ctx: Context, config?: Config): void {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Read the runtime config out of our own volatile config refs.
|
|
146
|
+
*
|
|
147
|
+
* This is the whole point of `volatile()`: the Loader updates those values
|
|
148
|
+
* **in place** (`ConfigEditor.edit` → `resolveConfig(fiber.runtime, next)` →
|
|
149
|
+
* `updateVolatile`) without rebuilding the fiber, so a held reference always
|
|
150
|
+
* answers `.get()` with the current value. Consequences worth remembering:
|
|
151
|
+
* - no `settings.describe()` per request (it walks and projects every entry
|
|
152
|
+
* in the profile — the reason this used to be slow);
|
|
153
|
+
* - no subscription or watcher is needed for *reading*;
|
|
154
|
+
* - the plugin keeps working when the Settings service is absent
|
|
155
|
+
* (issue #11: business logic must not depend on Settings).
|
|
156
|
+
*/
|
|
157
|
+
const readConfig = (): Partial<HubConfig> => {
|
|
158
|
+
const out: Record<string, unknown> = {}
|
|
159
|
+
for (const field of CONFIG_FIELDS) {
|
|
160
|
+
const ref = (config as unknown as Record<string, { get?: () => unknown } | undefined> | undefined)?.[field]
|
|
161
|
+
const value = ref !== undefined && typeof ref.get === 'function' ? ref.get() : ref
|
|
162
|
+
if (value !== undefined) out[field] = value
|
|
163
|
+
}
|
|
164
|
+
return out as Partial<HubConfig>
|
|
165
|
+
}
|
|
166
|
+
const current = (): HubConfig => resolveHubConfig({}, readConfig())
|
|
167
|
+
|
|
168
|
+
/** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
|
|
169
|
+
const settingsOf = (): SettingsForms | undefined => ctx.get('settings') as SettingsForms | undefined
|
|
114
170
|
|
|
115
171
|
const store = new SkillHubStore()
|
|
116
172
|
let disposeRoutes: (() => void) | undefined
|
|
@@ -126,26 +182,35 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
126
182
|
let stats: SkillStatsReader | undefined
|
|
127
183
|
|
|
128
184
|
// The raw saved config layer (fields the user explicitly overrode); the
|
|
129
|
-
// config route reports it so callers can mark overridden fields.
|
|
185
|
+
// config route reports it so callers can mark overridden fields. Empty when
|
|
186
|
+
// the deployment has no Settings service — the plugin still runs.
|
|
130
187
|
const saved = (): Partial<HubConfig> => {
|
|
131
|
-
const descriptor =
|
|
188
|
+
const descriptor = settingsOf()?.describe().find((entry) => entry.ns === ENTRY_ID)
|
|
132
189
|
return (descriptor?.user as Partial<HubConfig> | undefined) ?? {}
|
|
133
190
|
}
|
|
134
191
|
|
|
135
|
-
// Persist a config patch
|
|
136
|
-
//
|
|
137
|
-
// the
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// surfaces once the namespace commits.
|
|
192
|
+
// Persist a config patch through the settings transport. A patch value of
|
|
193
|
+
// undefined clears the saved override — expressed as a path `unset`, which
|
|
194
|
+
// makes the field fall back to its inherited value instead of writing a
|
|
195
|
+
// literal undefined (the old sidecar's reset semantics). The write reloads
|
|
196
|
+
// this Loader entry, so apply() re-runs and re-syncs every surface below.
|
|
141
197
|
const updateConfig = async (patch: Partial<HubConfig>): Promise<HubConfig> => {
|
|
142
|
-
const
|
|
143
|
-
for (const [
|
|
144
|
-
if (value === undefined)
|
|
145
|
-
else
|
|
198
|
+
const ops: SettingsPathOp[] = []
|
|
199
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
200
|
+
if (value === undefined) ops.push({ op: 'unset', path: [field] })
|
|
201
|
+
else ops.push({ op: 'set', path: [field], value })
|
|
202
|
+
}
|
|
203
|
+
const settings = settingsOf()
|
|
204
|
+
if (settings === undefined) throw new Error('this deployment does not mount the settings service; config is read-only')
|
|
205
|
+
if (ops.length > 0) {
|
|
206
|
+
await settings.mutate(ENTRY_ID, ops)
|
|
207
|
+
// A settings write updates this entry's values in place WITHOUT
|
|
208
|
+
// re-entering apply() (see `current`), so the section / provider / routes
|
|
209
|
+
// registered from the old values would otherwise keep serving them until
|
|
210
|
+
// a restart. Re-evaluate here, where we know the write landed.
|
|
211
|
+
sync()
|
|
146
212
|
}
|
|
147
|
-
|
|
148
|
-
return settingsScope.get()
|
|
213
|
+
return current()
|
|
149
214
|
}
|
|
150
215
|
|
|
151
216
|
// Register (or drop) every surface to match the current config. Each
|
|
@@ -165,8 +230,8 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
165
230
|
}
|
|
166
231
|
const value = current()
|
|
167
232
|
// GitHub auth for market/source API calls: env GITHUB_TOKEN/GH_TOKEN is the
|
|
168
|
-
// fallback (read at module load); an explicit settings value wins
|
|
169
|
-
//
|
|
233
|
+
// fallback (read at module load); an explicit settings value wins and is
|
|
234
|
+
// applied by re-running this sync from updateConfig after the write lands.
|
|
170
235
|
setGithubToken(value.githubToken)
|
|
171
236
|
if (value.announceToAgent) {
|
|
172
237
|
disposeSection = ctx.systemPrompt.section({
|
|
@@ -187,6 +252,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
187
252
|
'dsh-skill-hub: provider',
|
|
188
253
|
)
|
|
189
254
|
}
|
|
255
|
+
ctx.logger.info(`[dsh-skill-hub] surfaces synced: enabled=${value.enabled} announceToAgent=${value.announceToAgent}`)
|
|
190
256
|
if (disposeRoutes !== undefined) {
|
|
191
257
|
disposeRoutes()
|
|
192
258
|
disposeRoutes = undefined
|
|
@@ -210,14 +276,10 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
210
276
|
)
|
|
211
277
|
}
|
|
212
278
|
|
|
213
|
-
// Initial registration from the composition entry
|
|
214
|
-
//
|
|
215
|
-
//
|
|
279
|
+
// Initial registration from the composition entry. Later changes — the
|
|
280
|
+
// settings page, the config route, the Host document editor — rewrite this
|
|
281
|
+
// Loader entry, which re-enters apply(), so no in-process watcher is needed.
|
|
216
282
|
sync()
|
|
217
|
-
ctx.effect(
|
|
218
|
-
() => settingsScope.watch(() => { sync() }),
|
|
219
|
-
'dsh-skill-hub: settings config watch',
|
|
220
|
-
)
|
|
221
283
|
|
|
222
284
|
// One-time migration: an install upgraded from the sidecar-configured
|
|
223
285
|
// build seeds the settings namespace from the saved sidecar config when the
|
|
@@ -251,7 +313,8 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
251
313
|
try {
|
|
252
314
|
const legacy = await store.getConfig()
|
|
253
315
|
if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
|
|
254
|
-
|
|
316
|
+
const settings = settingsOf()
|
|
317
|
+
if (settings !== undefined) await settings.update(ENTRY_ID, legacy as Record<string, unknown>)
|
|
255
318
|
}
|
|
256
319
|
} catch (error) {
|
|
257
320
|
ctx.logger.warn('[dsh-skill-hub] sidecar config migration into the settings namespace failed', error)
|
package/src/protocol/api.ts
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Root path of the skill-hub API family. The host also registers this as its
|
|
3
|
+
* 404 catch-all prefix, so a mistyped path answers with a plain 404 naming the
|
|
4
|
+
* path instead of falling through to the SPA fallback (which answers 401 and
|
|
5
|
+
* reads like an auth problem).
|
|
6
|
+
*/
|
|
7
|
+
export const SKILL_HUB_API_ROOT = '/api/skill-hub'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Deprecated path of the market update check, kept routable for one release
|
|
11
|
+
* after the naming unification below. A browser tab that loaded the previous
|
|
12
|
+
* client bundle keeps calling it until it reloads; delete this (and its route
|
|
13
|
+
* in routes/market.ts) in the next minor.
|
|
14
|
+
*/
|
|
15
|
+
export const SKILL_HUB_API_DEPRECATED_MARKET_CHECK = '/api/skill-hub/market/check'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Browser-facing base paths of the skill-hub API family.
|
|
19
|
+
*
|
|
20
|
+
* Naming rule: a path's segments mirror its scope. Market sources own the
|
|
21
|
+
* `/market/source/*` subtree — add, delete, ref, versions, check, sync — so
|
|
22
|
+
* the update check and the sync that acts on its result sit side by side.
|
|
23
|
+
*/
|
|
2
24
|
export const SKILL_HUB_API = {
|
|
3
25
|
catalog: '/api/skill-hub/catalog',
|
|
4
26
|
skill: '/api/skill-hub/skill',
|
|
@@ -12,7 +34,7 @@ export const SKILL_HUB_API = {
|
|
|
12
34
|
marketSource: '/api/skill-hub/market/source',
|
|
13
35
|
marketSourceDelete: '/api/skill-hub/market/source/delete',
|
|
14
36
|
marketSourceRef: '/api/skill-hub/market/source/ref',
|
|
15
|
-
marketCheck: '/api/skill-hub/market/check',
|
|
37
|
+
marketCheck: '/api/skill-hub/market/source/check',
|
|
16
38
|
marketSync: '/api/skill-hub/market/source/sync',
|
|
17
39
|
repo: '/api/skill-hub/repo',
|
|
18
40
|
repoImport: '/api/skill-hub/repo/import',
|
package/src/protocol/config.ts
CHANGED
|
@@ -31,6 +31,15 @@ export interface HubConfig {
|
|
|
31
31
|
githubToken?: string
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* HubConfig minus the GitHub token: the shape of every config payload that
|
|
36
|
+
* leaves the host over HTTP. The token is write-only there — a response
|
|
37
|
+
* pasted into an issue or a screenshot would otherwise hand the credential
|
|
38
|
+
* over, so callers read `ConfigResponse.githubTokenSet` when they only need
|
|
39
|
+
* to know whether one is in effect.
|
|
40
|
+
*/
|
|
41
|
+
export type RedactedHubConfig = Omit<HubConfig, 'githubToken'>
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* The resolved shape of the hub's settings namespace (schema defaults, then
|
|
36
45
|
* the composition base, then the user layer). Kept as a type alias so the
|
|
@@ -55,6 +64,15 @@ export type HubSettingsValue = {
|
|
|
55
64
|
githubToken?: string
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
/**
|
|
68
|
+
* This plugin's Loader entry id — the settings namespace dsh serves its config
|
|
69
|
+
* under. It is cordis.patch.yml's insert id, NOT the package name, and it is
|
|
70
|
+
* shared by contract because the browser half addresses the same form through
|
|
71
|
+
* `ctx.configForms.get(HUB_ENTRY_ID)` while the host half writes through
|
|
72
|
+
* `ctx.settings.update(HUB_ENTRY_ID, …)`.
|
|
73
|
+
*/
|
|
74
|
+
export const HUB_ENTRY_ID = 'skill-hub'
|
|
75
|
+
|
|
58
76
|
/**
|
|
59
77
|
* Hub config defaults — the single source every layer reads: the cordis
|
|
60
78
|
* schema (index.ts), the host's saved-override merge, and the routes'
|
|
@@ -103,6 +121,17 @@ function clampNumber(value: unknown, min: number): number | undefined {
|
|
|
103
121
|
return Math.floor(value)
|
|
104
122
|
}
|
|
105
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Drop the GitHub token from a config-shaped object. Returns a copy, so the
|
|
126
|
+
* caller's own config layer keeps the token. Shared by the config route's GET
|
|
127
|
+
* and POST responses — neither may echo it back.
|
|
128
|
+
*/
|
|
129
|
+
export function redactGithubToken<T extends { githubToken?: string }>(value: T): Omit<T, 'githubToken'> {
|
|
130
|
+
const copy: Record<string, unknown> = { ...value }
|
|
131
|
+
delete copy.githubToken
|
|
132
|
+
return copy as Omit<T, 'githubToken'>
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
/** HEX color validation shared by host routes and the settings card. */
|
|
107
136
|
export const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i
|
|
108
137
|
|
|
@@ -111,10 +140,12 @@ export interface ConfigResponse {
|
|
|
111
140
|
ok: true
|
|
112
141
|
/** 已安装插件自身的版本号(package.json version),设置卡标题旁显示。 */
|
|
113
142
|
pluginVersion: string
|
|
114
|
-
/** Effective configuration (saved overrides merged over the defaults). */
|
|
115
|
-
config:
|
|
116
|
-
/** Raw user overrides persisted in the sidecar (absent fields inherit defaults). */
|
|
117
|
-
saved: Partial<
|
|
143
|
+
/** Effective configuration (saved overrides merged over the defaults), token stripped. */
|
|
144
|
+
config: RedactedHubConfig
|
|
145
|
+
/** Raw user overrides persisted in the sidecar (absent fields inherit defaults), token stripped. */
|
|
146
|
+
saved: Partial<RedactedHubConfig>
|
|
147
|
+
/** True when a GitHub token is in effect (saved override or the base/env layer). */
|
|
148
|
+
githubTokenSet: boolean
|
|
118
149
|
}
|
|
119
150
|
|
|
120
151
|
/** POST /api/skill-hub/config — a partial patch; omitted fields keep their values. */
|
package/src/protocol/market.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface MarketStatsResponse {
|
|
|
69
69
|
}>
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/** GET /api/skill-hub/market/check — update check over market sources. */
|
|
72
|
+
/** GET /api/skill-hub/market/source/check — update check over market sources. */
|
|
73
73
|
export interface MarketCheckResponse {
|
|
74
74
|
ok: true
|
|
75
75
|
results: Array<{
|
package/src/protocol/repo.ts
CHANGED
|
@@ -18,9 +18,30 @@ export interface RepoSkillEntry {
|
|
|
18
18
|
existing: boolean
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Skill root in a GitHub repo: the top-level directory that contains skills
|
|
23
|
+
* (e.g. skills, design-templates, templates). Auto-derived from SKILL.md
|
|
24
|
+
* locations, not hard-coded.
|
|
25
|
+
*
|
|
26
|
+
* The empty string is the repo root itself — a skill whose SKILL.md sits at
|
|
27
|
+
* the top of the tree (a Claude Code plugin manifest may declare
|
|
28
|
+
* `"skills": ["./"]`). It is a legal value, not a missing one, so state
|
|
29
|
+
* sanitizers must accept it instead of coercing it to a default root.
|
|
30
|
+
*/
|
|
22
31
|
export type RepoRoot = string
|
|
23
32
|
|
|
33
|
+
/** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
|
|
34
|
+
export const REPO_ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True when a value is a usable root: a visible safe top-level directory name,
|
|
38
|
+
* or the empty string for the repo root. Guards the persisted store against
|
|
39
|
+
* corrupt roots without rewriting the repo-root sentinel.
|
|
40
|
+
*/
|
|
41
|
+
export function isValidRepoRoot(value: unknown): value is RepoRoot {
|
|
42
|
+
return typeof value === 'string' && (value === '' || REPO_ROOT_RE.test(value))
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/** GET /api/skill-hub/repo — discover importable skills in a GitHub repo. */
|
|
25
46
|
export interface RepoDiscoverResponse {
|
|
26
47
|
ok: true
|