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.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +6 -4
  2. package/README.md +9 -5
  3. package/README.zh.md +9 -5
  4. package/lib/client.js +55 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +529 -315
  7. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -3
  8. package/lib/types/client/icons.d.ts +4 -5
  9. package/lib/types/client/index.d.ts +13 -12
  10. package/lib/types/client/locales/market.d.ts +1 -0
  11. package/lib/types/client/locales.d.ts +1 -0
  12. package/lib/types/client/settings-form.d.ts +9 -3
  13. package/lib/types/client/slash-dots.d.ts +20 -11
  14. package/lib/types/index.d.ts +59 -24
  15. package/lib/types/protocol/api.d.ts +22 -2
  16. package/lib/types/protocol/config.d.ts +30 -4
  17. package/lib/types/protocol/market.d.ts +1 -1
  18. package/lib/types/protocol/repo.d.ts +18 -1
  19. package/lib/types/protocol.d.ts +3 -3
  20. package/lib/types/repo/discovery.d.ts +59 -3
  21. package/lib/types/repo/github-client.d.ts +16 -0
  22. package/lib/types/repo.d.ts +6 -3
  23. package/lib/types/routes/helpers.d.ts +8 -1
  24. package/lib/types/routes/market.d.ts +2 -2
  25. package/lib/types/routes.d.ts +1 -1
  26. package/lib/types/store/store.d.ts +4 -0
  27. package/package.json +31 -41
  28. package/src/client/SkillHubSettingsCard.tsx +5 -3
  29. package/src/client/icons.tsx +4 -5
  30. package/src/client/index.tsx +33 -32
  31. package/src/client/locales/market.ts +2 -0
  32. package/src/client/panel/RepoScanCard.tsx +6 -3
  33. package/src/client/panel/SkillHubPanel.tsx +1 -1
  34. package/src/client/panel/SourcesView.tsx +1 -1
  35. package/src/client/panel/hooks/useGroupFlow.ts +1 -8
  36. package/src/client/settings-form.ts +9 -4
  37. package/src/client/slash-dots.test.ts +19 -36
  38. package/src/client/slash-dots.tsx +30 -60
  39. package/src/index.ts +145 -82
  40. package/src/protocol/api.ts +24 -2
  41. package/src/protocol/config.ts +35 -4
  42. package/src/protocol/market.ts +1 -1
  43. package/src/protocol/repo.ts +22 -1
  44. package/src/protocol.ts +3 -3
  45. package/src/repo/discovery.ts +120 -33
  46. package/src/repo/github-client.ts +18 -1
  47. package/src/repo/install.ts +8 -7
  48. package/src/repo.test.ts +158 -4
  49. package/src/repo.ts +10 -2
  50. package/src/routes/config.ts +19 -2
  51. package/src/routes/helpers.ts +9 -2
  52. package/src/routes/market.ts +49 -37
  53. package/src/routes/repo-import.ts +3 -2
  54. package/src/routes/sources.ts +2 -1
  55. package/src/routes.test.ts +107 -1
  56. package/src/routes.ts +21 -3
  57. package/src/store/migrate.ts +3 -2
  58. package/src/store/store.ts +7 -2
  59. package/src/store.test.ts +50 -0
  60. 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 (mirrors how dsh-at-file fills the menu icon slot): the candidate
6
- * menu's rows already render an optional `icon` slot (`MenuView` renders
7
- * `item.icon` in a 16×16 leading span when it's defined), but the core `/skill`
8
- * source (`dsh-client-ui-skill`) returns candidates without `icon`. This module
9
- * wraps that source's `candidates` and stamps each row with a colored dot,
10
- * reusing the same settings (dotModelColor / dotUserColor) and the same
11
- * `modelInvocable` classification the panel legend uses — so the chat menu and
12
- * the Settings → 技能 panel stay in sync, and editing the color updates both.
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 { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
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 { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
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
- * One menu-row dot element. Inline span so it needs no CSS module; the
96
- * candidate menu centers it inside its 16×16 leading icon slot.
97
- * @param color - the dot's background color.
98
- * @returns a React node (memory-only, never crosses the Host boundary).
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
- // 复刻旧版 icon 槽:16×16 容器居中 6px 点,与升级前 `dotIcon` 在 `itemIcon` 内的效果一致
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
- // 新版无 icon 槽,直接插在名称前,与旧版 `itemIcon` 位置一致
140
+ // 直接插在名称前,与菜单行原有 icon 槽的位置一致
167
141
  nameEl.parentElement?.insertBefore(wrapper, nameEl)
168
142
  }
169
143
  }
170
144
  requestAnimationFrame(() => setTimeout(run, 0))
171
145
  }
172
146
 
173
- export function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, scope: SettingsScope<HubSettingsValue>): () => void {
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
- if (isNewHost) {
188
- // 新版:不通过 icon(枚举限制),改为 DOM 注入
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: SettingsScope<HubSettingsValue>,
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
- import z from 'schemastery'
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, type HubConfig, type HubSettingsValue } from './protocol.ts'
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', 'settings']
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
- /** Plugin config, validated by the same-named schemastery schema. */
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?: boolean
51
+ announceToAgent: Volatile<boolean>
40
52
  /** Master switch for the plugin (routes, prompt section). */
41
- enabled?: boolean
53
+ enabled: Volatile<boolean>
42
54
  /** Show per-skill invocation count chip. Default true. */
43
- showUseCount?: boolean
55
+ showUseCount: Volatile<boolean>
44
56
  /** Show per-skill last-used relative time. Default true. */
45
- showUseTime?: boolean
57
+ showUseTime: Volatile<boolean>
46
58
  /** Show group-header usage summaries (count + last used). Default true. */
47
- showGroupSummary?: boolean
48
- /** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
49
- statsWindowDays?: number
50
- /** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
51
- statsScanMinutes?: number
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
- export const Config: z<Config> = z.object({
55
- announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent),
56
- enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
57
- showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
58
- showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
59
- showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
60
- statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
61
- statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes),
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
- * Settings namespace hosting the hub's runtime config. Since dsh rc.7 the
66
- * host serves every registered settings namespace to the web client (the
67
- * dsh-host-apiproxy allowlist is gone), so the browser card and the settings
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 CONFIG_NAMESPACE = 'dsh-skill-hub' as SettingsNamespace
114
+ export const ENTRY_ID = HUB_ENTRY_ID as SettingsNamespace
72
115
 
73
- /** Schema of the hub's settings namespace: the card's fields (booleans + optional dot colors). */
74
- export const HubSettingsSchema: z<HubSettingsValue> = z.object({
75
- enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
76
- announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent),
77
- showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
78
- showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
79
- showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
80
- dotModelColor: z.string().pattern(HEX_COLOR_RE),
81
- dotUserColor: z.string().pattern(HEX_COLOR_RE),
82
- githubToken: z.string(),
83
- statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
84
- statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes),
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 技能中枢):设置 →「技能」分区为管理主页;设置 → 插件列表中有本插件的配置卡片(启用/公告开关)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由仅回环可访问。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。',
93
- 'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page; Settings → Plugins lists this plugin\'s configuration card (enable / announcement toggles). 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.'
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
- // The hub's runtime configuration lives in dsh's own settings service:
103
- // since rc.7 the host serves every registered settings namespace to the
104
- // web client (dsh-host-apiproxy's allowlist is gone), so the browser card
105
- // and the config route edit this namespace through the official settings
106
- // transport, and the host consumes the very same resolved value — one
107
- // source of truth. The cordis composition entry seeds the base layer; the
108
- // sidecar config survives only as a one-time migration source below.
109
- const base = config ?? {}
110
- const settingsScope = ctx.settings.register(CONFIG_NAMESPACE, HubSettingsSchema, { base })
111
- // The effective resolved config, read live from the settings namespace
112
- // (schema defaults, then the composition base, then the user layer).
113
- const current = (): HubConfig => settingsScope.get()
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 = ctx.settings.describe().find((entry) => entry.ns === CONFIG_NAMESPACE)
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, re-point the live config, and re-sync every
136
- // surface through the settings transport. A patch value of undefined clears
137
- // the saved override (the key leaves the user section, so the field
138
- // re-inherits the base/default) — the old sidecar's reset semantics.
139
- // Runs inside the config route handler; the watcher below re-syncs the
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 user: Record<string, unknown> = { ...saved() }
143
- for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | number | undefined]>) {
144
- if (value === undefined) delete user[key]
145
- else user[key] = value
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
- await settingsScope.replace(user)
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 live and
169
- // applies without a restart via the settings watcher below.
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, then re-sync whenever
214
- // the settings namespace commits (any writer — the card, the config route,
215
- // or the Host document editor).
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
- await settingsScope.update(legacy as Record<string, unknown>)
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)
@@ -1,4 +1,26 @@
1
- /** Browser-facing base paths of the skill-hub API family. */
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',
@@ -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: HubConfig
116
- /** Raw user overrides persisted in the sidecar (absent fields inherit defaults). */
117
- saved: Partial<HubConfig>
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. */
@@ -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<{
@@ -18,9 +18,30 @@ export interface RepoSkillEntry {
18
18
  existing: boolean
19
19
  }
20
20
 
21
- /** Skill root in a GitHub repo: the top-level directory that contains skills (e.g. skills, design-templates, templates). Auto-derived from SKILL.md locations, not hard-coded. */
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