@weibaohui/skills-management 0.1.6 → 0.2.0

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/README.md CHANGED
@@ -9,11 +9,11 @@
9
9
 
10
10
  ## 核心功能
11
11
 
12
- - **全来源扫描**:自动扫描本机各 coding agent 的技能目录(Claude、ZCode、Codex 等十余个执行器),统一在一个页面查看、查看详情、单文件预览
12
+ - **全来源扫描**:自动扫描本机各 coding agent 的技能目录(Claude、ZCode、Codex、WorkBuddy 等十余个执行器),统一在一个页面查看、查看详情、单文件预览
13
13
  - **一键收编**:任意执行器的技能可一键复制进 DSH 用户库,供模型的 `skill` 工具直接调用
14
14
  - **技能市场**:内置 ntd 技能合集(6600+ 条),按来源分组浏览、搜索筛选、详情预览、一键安装
15
15
  - **模型可见性治理**:每个已装技能都有「模型可调用」开关,不想暴露给模型的技能一键隐藏/恢复
16
- - **输入框 +技能**:composer 工具行新增「+技能」按钮,弹出技能候选菜单,选中即把 `/技能名` 写入草稿,发送时技能内容注入该条消息
16
+ - **输入框 + 技能**:composer 工具行新增「+ 技能」按钮,弹出带搜索框的技能候选浮层(候选 = 宿主技能注册表,与 `/` 菜单同源;支持键盘 ↑/↓/Enter/Esc),选中即把 `/技能名` 写入草稿,发送时技能内容注入该条消息
17
17
  - **市场自动同步**:市场仓库自动克隆与每日更新(可关),支持 GitCode 私有仓库 access token
18
18
  - **稀疏检出**:ntd-resource 仓库同时携带专家/模板等子树,市场只检出 `skills` 子目录(git partial clone + sparse-checkout),省一半以上流量与磁盘;已有全量检出会在下次同步时原地转换
19
19
  - **软链布局兼容**:各执行器目录间软链共享的技能不会重复展示
@@ -28,7 +28,11 @@ dsh plugin --profile web add @weibaohui/skills-management -w
28
28
 
29
29
  ## 使用
30
30
 
31
- 1. 打开 Web UI → 侧栏进入 **技能市场** 页面
31
+ 1. 打开 Web UI → **设置** 左侧「技能市场」section 即完整管理页(可搭配 dsh-settings-ui 插件把设置窗口调大/全屏)
32
32
  2. 「已安装」视图管理本机技能;「市场」视图浏览/搜索/安装 ntd 合集技能;「执行器」视图按来源钻入查看各 coding agent 的技能
33
33
  3. 详情页可预览 SKILL.md 全文、安装到用户库、切换模型可调用开关
34
34
  4. ⚙ 设置面板里可配置市场仓库地址、分支、access token 与自动同步
35
+
36
+ ## 联系我 :飞书群
37
+
38
+ ![link](https://foruda.gitee.com/images/1774880015525784725/4fd67005_77493.png "link")
package/client/bundle.js CHANGED
@@ -11,7 +11,8 @@ window.__ModuleLoader__.load({
11
11
  /**
12
12
  * dsh-plugin-skills-management - Browser half.
13
13
  *
14
- * One React app for every surface (sidebar overlay + settings section).
14
+ * One React app for every surface (settings section; the former sidebar
15
+ * full-page entry was retired — pair with dsh-settings-ui for room).
15
16
  * All interactive controls are host primitives (@deepseek-ai/dsh-client-ui-
16
17
  * primitives); all colors come from the ui-theme `--dsw-*` token layers so
17
18
  * light/dark follows the shell; all copy comes from the locale registry
@@ -74,15 +75,24 @@ window.__ModuleLoader__.load({
74
75
  let sessionsApi = null
75
76
  const sessionsSvc = () => sessionsApi
76
77
 
77
- // Composer services (inputTriggers + sessions) for the +技能 button: opens
78
- // the `skill` source (registered by dsh-client-ui-skill) as a menu. Absence
79
- // hides the button; nothing else depends on it.
78
+ // Composer services (inputTriggers + sessions) for the 技能 button plus
79
+ // the `connection` service for the picker's skill catalog: the button opens
80
+ // the plugin's own searchable picker popover (the host slash menu filters
81
+ // only by a typed query, which a button click cannot provide); the pick is
82
+ // written into the draft through the same scoped `slash/input-insert-text`
83
+ // event the host menu executes. Absence hides the button; nothing else
84
+ // depends on it.
80
85
  let composerScope = null
86
+ let connectionApi = null
81
87
 
82
88
  /**
83
89
  * Open one registered '/' source over a synthetic collapsed span appended at
84
90
  * the draft end (host toggleCommandMenu 同款调用形状;标准 kit 不暴露光标,
85
91
  * pick 依赖 span-CAS:点击后草稿若再变动则本次 pick 静默作废)。
92
+ *
93
+ * NOTE: the + 技能 button no longer uses this — the host menu cannot offer
94
+ * a search box, so the button opens SkillPicker instead. Kept for the
95
+ * contract tests and as the documented toggleSource path.
86
96
  */
87
97
  function openTriggerSource(scope, sessionId, input, sourceName) {
88
98
  const inputTriggers = scope && scope.inputTriggers
@@ -105,6 +115,127 @@ window.__ModuleLoader__.load({
105
115
  return true
106
116
  }
107
117
 
118
+ /**
119
+ * Insert `text` at the end of the session draft through the same scoped
120
+ * event the host slash menu executes (`slash/input-insert-text`). The span
121
+ * CAS uses the freshest input snapshot handed to the slot props — while the
122
+ * picker popover is open the composer draft cannot move (focus is in the
123
+ * picker), so the splice applies; a stale snapshot quietly no-ops, same as
124
+ * the host menu's span-CAS.
125
+ */
126
+ function insertComposerText(scope, sessionId, input, text) {
127
+ const sessions = scope && scope.sessions
128
+ if (!sessions) return false
129
+ let actx
130
+ try { actx = sessions.scope(sessionId) } catch { return false }
131
+ if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
132
+ const draft = (input && input.draft) || ''
133
+ const at = draft.length
134
+ try {
135
+ return actx.bail(actx, 'slash/input-insert-text', {
136
+ text,
137
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
138
+ }) === true
139
+ } catch { return false }
140
+ }
141
+
142
+ /** Best-effort refocus of the composer textarea after the picker closes. */
143
+ function refocusComposer() {
144
+ try {
145
+ const card = document.querySelector('[data-composer-card]')
146
+ const ta = card && card.querySelector('textarea')
147
+ if (ta && typeof ta.focus === 'function') ta.focus()
148
+ } catch {}
149
+ }
150
+
151
+ /** Picker popover list cap — beyond this the search input is the filter. */
152
+ const PICKER_ROW_CAP = 200
153
+
154
+ /** Skill catalog cache for the picker (ui-skill 同源:connection.api.skills). */
155
+ let skillCatalog = { sessionId: null, at: 0, rows: null }
156
+ const SKILL_CATALOG_TTL = 60_000
157
+
158
+ /**
159
+ * Picker candidates from the host skill registry (the same list the `/`
160
+ * skill source shows). Subagent sessions have no catalog (ui-skill 同款守卫);
161
+ * a failed/absent connection rejects → the picker shows its empty state.
162
+ */
163
+ async function fetchSkillCandidates(connection, sessions, sessionId) {
164
+ try { if (sessions && typeof sessions.subagentAddress === 'function' && sessions.subagentAddress(sessionId) !== undefined) return [] } catch {}
165
+ const now = Date.now()
166
+ if (skillCatalog.rows !== null && skillCatalog.sessionId === sessionId && now - skillCatalog.at < SKILL_CATALOG_TTL) return skillCatalog.rows
167
+ const skills = connection && connection.api && connection.api.skills
168
+ if (!skills || typeof skills.list !== 'function') throw new Error('connection.api.skills unavailable')
169
+ const res = await skills.list({ sessionId })
170
+ const result = res && res.result
171
+ if (!result || result.ok !== true) throw new Error('skill.list failed')
172
+ const list = result.value && Array.isArray(result.value.skills) ? result.value.skills : []
173
+ const rows = list.map((s) => ({ name: s.name, description: s.description || '', modelInvocable: s.modelInvocable !== false }))
174
+ skillCatalog = { sessionId, at: now, rows }
175
+ return rows
176
+ }
177
+
178
+ /**
179
+ * + 技能 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
180
+ * 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
181
+ * 太多时无从筛选,所以浮层自带搜索框。键盘 ↑/↓/Enter/Esc,鼠标 hover+点击。
182
+ */
183
+ function SkillPicker(props) {
184
+ const t = props.t
185
+ const [query, setQuery] = useState('')
186
+ const [active, setActive] = useState(0)
187
+ const inputRef = useRef(null)
188
+ const listRef = useRef(null)
189
+ useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
190
+ useEffect(() => {
191
+ const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
192
+ try { document.addEventListener('keydown', onKey) } catch {}
193
+ return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
194
+ }, [])
195
+ const lower = query.trim().toLowerCase()
196
+ const all = props.rows || []
197
+ const filtered = lower === '' ? all : all.filter((r) => matchSkill(r, lower))
198
+ const shown = filtered.slice(0, PICKER_ROW_CAP)
199
+ useEffect(() => { setActive(0) }, [lower, props.rows])
200
+ useEffect(() => {
201
+ const list = listRef.current
202
+ const el = list && list.children[active]
203
+ if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
204
+ }, [active])
205
+ const onKeyDown = (e) => {
206
+ // IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
207
+ if (e.nativeEvent && e.nativeEvent.isComposing === true) return
208
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
209
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
210
+ else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
211
+ }
212
+ const width = 400
213
+ const winW = typeof window !== 'undefined' ? window.innerWidth : 800
214
+ const winH = typeof window !== 'undefined' ? window.innerHeight : 600
215
+ const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
216
+ const bottom = Math.max(8, winH - props.anchor.top + 6)
217
+ return h('div', { className: 'sk-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
218
+ h('div', { className: 'sk-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickSkillTitle') },
219
+ h('input', {
220
+ ref: inputRef, className: 'sk-input sk-picker-input', value: query,
221
+ placeholder: t('pickerSearch'), onChange: (e) => setQuery(e.target.value), onKeyDown,
222
+ }),
223
+ h('div', { className: 'sk-picker-list', ref: listRef, role: 'listbox' },
224
+ props.rows === null
225
+ ? h('div', { className: 'sk-picker-empty' }, t('pickerLoading'))
226
+ : shown.length === 0
227
+ ? h('div', { className: 'sk-picker-empty' }, t('emptySearch'))
228
+ : shown.map((row, i) => h('button', {
229
+ key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
230
+ className: 'sk-picker-row', 'data-active': i === active,
231
+ onMouseEnter: () => setActive(i),
232
+ onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
233
+ },
234
+ h('span', { className: 'sk-picker-name' }, row.name),
235
+ h('span', { className: 'sk-picker-desc' },
236
+ row.modelInvocable ? row.description : `${t('pickerUserOnly')} · ${row.description}`))))))
237
+ }
238
+
108
239
  // ── Locale ───────────────────────────────────────────────────────────────
109
240
 
110
241
  const NS = 'skillsManagement'
@@ -136,7 +267,7 @@ window.__ModuleLoader__.load({
136
267
  saved: '设置已保存',
137
268
  gitMissing: '未检测到 git',
138
269
  repoDirLabel: '本地目录(同步内容存放处)',
139
- tokenLabel: '访问令牌(私有仓库需要)',
270
+ tokenLabel: '访问令牌(分享到社区/私有仓库需要)',
140
271
  tokenConfigured: '已配置',
141
272
  clearToken: '清除',
142
273
  shareBtn: '分享',
@@ -214,8 +345,11 @@ window.__ModuleLoader__.load({
214
345
  invocationHint: '关闭后技能保留在库里,但不再注入对话目录(skill 工具也调不到)',
215
346
  pathLabel: '路径',
216
347
  meTag: '本机',
217
- pickSkill: '+技能',
348
+ pickSkill: '+ 技能',
218
349
  pickSkillTitle: '选择一个技能,其内容将注入本条消息',
350
+ pickerSearch: '搜索技能名称或描述…',
351
+ pickerLoading: '正在加载技能目录…',
352
+ pickerUserOnly: '仅用户',
219
353
  }
220
354
 
221
355
  const EN = {
@@ -245,7 +379,7 @@ window.__ModuleLoader__.load({
245
379
  saved: 'Settings saved',
246
380
  gitMissing: 'git not found',
247
381
  repoDirLabel: 'Local directory (sync target)',
248
- tokenLabel: 'Access token (private repos)',
382
+ tokenLabel: 'Access token (community sharing / private repos)',
249
383
  tokenConfigured: 'configured',
250
384
  clearToken: 'Clear',
251
385
  shareBtn: 'Share',
@@ -325,6 +459,9 @@ window.__ModuleLoader__.load({
325
459
  meTag: 'me',
326
460
  pickSkill: '+ Skill',
327
461
  pickSkillTitle: 'Pick a skill; its content is injected into this message',
462
+ pickerSearch: 'Search skills by name or description…',
463
+ pickerLoading: 'Loading skill catalog…',
464
+ pickerUserOnly: 'user-only',
328
465
  }
329
466
 
330
467
  // ── Pure helpers ────────────────────────────────────────────────────────
@@ -469,6 +606,15 @@ window.__ModuleLoader__.load({
469
606
  .sk-tabpill{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}
470
607
  .sk-chip{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family);font-size:12px;cursor:pointer;white-space:nowrap}
471
608
  .sk-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
609
+ .sk-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
610
+ .sk-picker{position:fixed;z-index:2147483201;width:400px;max-width:92vw;max-height:360px;display:flex;flex-direction:column;gap:4px;padding:6px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
611
+ .sk-picker-input{flex:none;box-sizing:border-box;width:100%}
612
+ .sk-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
613
+ .sk-picker-row{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;font-family:var(--dsw-font-family);font-size:13px}
614
+ .sk-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
615
+ .sk-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
616
+ .sk-picker-desc{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
617
+ .sk-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
472
618
  </style>`
473
619
 
474
620
  // ── Fetch layer ─────────────────────────────────────────────────────────
@@ -1259,61 +1405,16 @@ window.__ModuleLoader__.load({
1259
1405
 
1260
1406
  // ── Slot entries ─────────────────────────────────────────────────────────
1261
1407
 
1262
- function footerStyle() {
1263
- return { display: 'inline-flex', alignItems: 'center', gap: 6, margin: '4px 10px', padding: '8px 10px',
1264
- border: 'none', borderRadius: 8, background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
1265
- font: 'inherit', fontSize: 13, cursor: 'pointer', width: 'calc(100% - 20px)', textAlign: 'left' }
1266
- }
1267
-
1268
- /** Panel state lives OUTSIDE React: sidebar churn remounts slot entries,
1269
- * and any state kept in them (the old bug) is torn down with them. */
1270
- const panelStore = {
1271
- open: false,
1272
- listeners: new Set(),
1273
- set(v) { panelStore.open = v; for (const fn of panelStore.listeners) fn(v) },
1274
- subscribe(fn) { panelStore.listeners.add(fn); return () => panelStore.listeners.delete(fn) },
1275
- }
1276
-
1277
1408
  /** Jump to the run's conversation: open() is best-effort (it may reject
1278
- * after the selection lands), but folding our overlay must always happen. */
1409
+ * after the selection lands). */
1279
1410
  function openRunSession(sessionId) {
1280
1411
  try {
1281
1412
  const svc = sessionsSvc()
1282
1413
  if (svc && typeof svc.open === 'function') svc.open(sessionId)
1283
1414
  } catch {}
1284
- panelStore.set(false)
1285
1415
  return true
1286
1416
  }
1287
1417
 
1288
- /** Footer slot entry: the button, and — when open — the whole market page
1289
- * through the host primitives Modal (portal + overlay handled by the host's
1290
- * own React tree; no custom createRoot, which never commits here). */
1291
- function FooterSlotComponent(props) {
1292
- const [open, setOpen] = useState(panelStore.open)
1293
- useEffect(() => panelStore.subscribe(setOpen), [])
1294
- useEffect(ensureStyles, [])
1295
-
1296
- const t = props.__t
1297
- const labelText = t ? t('title') : 'Skills Market'
1298
- // The sidebar renders this entry with a `wide` owner prop: the collapsed
1299
- // rail passes false and shows the icon alone; expanded shows the label.
1300
- const wide = props.wide !== false
1301
- return h('span', { style: { display: 'contents' } },
1302
- h('button', { title: labelText, 'aria-label': labelText, onClick: () => panelStore.set(!panelStore.open),
1303
- style: footerStyle() },
1304
- P && P.IconSkillOutline16 ? h(P.IconSkillOutline16, { size: 16 }) : '\u{1F3AF}',
1305
- wide ? ' ' + labelText : ''),
1306
- open && (() => {
1307
- const page = h(SkillsPage, { t, embedded: false, onClose: () => panelStore.set(false) })
1308
- // Fullscreen: portal the fixed-position page to <body> so no sidebar
1309
- // ancestor (transform-containing or otherwise) can clip it.
1310
- if (RDP && typeof RDP.createPortal === 'function' && typeof document !== 'undefined') {
1311
- return RDP.createPortal(page, document.body)
1312
- }
1313
- return page // fallback: fixed positioning still applies from here
1314
- })())
1315
- }
1316
-
1317
1418
  /** Settings section slot entry: render the page directly in the host tree. */
1318
1419
  function SettingsSlotComponent(props) {
1319
1420
  useEffect(ensureStyles, [])
@@ -1327,7 +1428,7 @@ window.__ModuleLoader__.load({
1327
1428
  module.exports = {
1328
1429
  name: CLIENT_NAME,
1329
1430
  inject: ['slots', 'locale'],
1330
- __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource },
1431
+ __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource, insertComposerText, fetchSkillCandidates },
1331
1432
  /** Test/host helper: mount a standalone page into any container. */
1332
1433
  __boot(container, opts = {}) {
1333
1434
  ensureStyles()
@@ -1355,13 +1456,20 @@ window.__ModuleLoader__.load({
1355
1456
  })
1356
1457
  }
1357
1458
  } catch {}
1358
- // Composer services (inputTriggers + sessions) for the +技能 button;
1459
+ // Composer services (inputTriggers + sessions) for the 技能 button;
1359
1460
  // absence hides the button only.
1360
1461
  try {
1361
1462
  if (typeof ctx.inject === 'function') {
1362
1463
  ctx.inject(['inputTriggers', 'sessions'], (scope) => { composerScope = scope })
1363
1464
  }
1364
1465
  } catch {}
1466
+ // connection service for the picker skill catalog (host skill registry,
1467
+ // ui-skill 同源); absence keeps the button hidden.
1468
+ try {
1469
+ if (typeof ctx.inject === 'function') {
1470
+ ctx.inject(['connection'], (scope) => { connectionApi = scope && scope.connection })
1471
+ }
1472
+ } catch {}
1365
1473
  // Locale service is optional at boot order — degrade to EN until present
1366
1474
  let t = (key, vars) => {
1367
1475
  let out = EN[key] ?? key
@@ -1383,21 +1491,6 @@ window.__ModuleLoader__.load({
1383
1491
  }
1384
1492
  }
1385
1493
  } catch (e) { try { console.error('[skills-management] locale init:', e) } catch {} }
1386
- ctx.effect(() => {
1387
- try {
1388
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
1389
- name: 'sidebar.footer.action',
1390
- id: CLIENT_NAME,
1391
- order: 50,
1392
- locale: NS,
1393
- label: () => t('title'),
1394
- inject: () => ({ t }),
1395
- }, function FooterSlot(apiProps) {
1396
- // ownerProps (the sidebar's wide flag) land here — forward them
1397
- return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
1398
- }))
1399
- } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('footer:' + (e && e.message)); throw e }
1400
- }, 'skills-management: sidebar footer action')
1401
1494
  ctx.effect(() => {
1402
1495
  try {
1403
1496
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -1424,7 +1517,7 @@ window.__ModuleLoader__.load({
1424
1517
  inject: () => ({ t }),
1425
1518
  }, function SkillButtonSlot(apiProps) {
1426
1519
  return h(ComposerButtonSlot, {
1427
- __t: t, icon: '⚡', label: t('pickSkill'), title: t('pickSkillTitle'),
1520
+ __t: t, label: t('pickSkill'), title: t('pickSkillTitle'),
1428
1521
  source: 'skill', sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
1429
1522
  })
1430
1523
  }))
@@ -1433,18 +1526,49 @@ window.__ModuleLoader__.load({
1433
1526
  },
1434
1527
  }
1435
1528
 
1436
- /** Composer tool-row button: opens a registered '/' source (ui-skill's
1437
- * `skill` source) over the session's trigger controller. Hidden while the
1438
- * inputTriggers/sessions services are absent. */
1529
+ /** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
1530
+ * 技能 picker 浮层(候选 = 宿主技能注册表,ui-skill 同源);pick
1531
+ * slash/input-insert-text 写入 `/<name> `。浮层背板盖住按钮以外的区域,
1532
+ * 再点一次按钮会先落在背板上——天然形成开关切换。
1533
+ * connection 缺席(picker 无目录来源)时回退旧的 toggleSource 宿主菜单;
1534
+ * inputTriggers/sessions 缺席时按钮隐藏(与旧行为一致)。 */
1439
1535
  function ComposerButtonSlot(props) {
1440
1536
  useEffect(ensureStyles, [])
1537
+ const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
1538
+ const [rows, setRows] = useState(null) // null = 加载中
1539
+ const btnRef = useRef(null)
1540
+ const liveInput = useRef(props.input)
1541
+ liveInput.current = props.input
1441
1542
  const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
1442
1543
  if (!ready) return null
1544
+ const close = () => setPicker(null)
1545
+ const open = () => {
1546
+ // 目录来源缺席 → 退回宿主斜杠菜单(无搜索,但按钮不消失)
1547
+ if (!connectionApi) { openTriggerSource(composerScope, props.sessionId, liveInput.current, props.source); return }
1548
+ let anchor = { left: 16, top: 160 }
1549
+ try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
1550
+ setPicker({ left: anchor.left, top: anchor.top })
1551
+ setRows(null)
1552
+ fetchSkillCandidates(connectionApi, composerScope.sessions, props.sessionId)
1553
+ .then((list) => setRows(Array.isArray(list) ? list : []))
1554
+ .catch(() => setRows([]))
1555
+ }
1556
+ const pick = (row) => {
1557
+ insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
1558
+ close()
1559
+ refocusComposer()
1560
+ }
1561
+ const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
1562
+ ? RDP.createPortal(h(SkillPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
1563
+ : null
1443
1564
  return h('button', {
1444
1565
  className: 'sk-chip',
1566
+ ref: btnRef,
1445
1567
  title: props.title || props.label,
1446
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
1447
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
1568
+ 'aria-haspopup': 'dialog',
1569
+ 'aria-expanded': picker !== null,
1570
+ onClick: open,
1571
+ }, props.label, popover)
1448
1572
  }
1449
1573
 
1450
1574
  return module.exports
package/client/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * dsh-plugin-skills-management - Browser half.
3
3
  *
4
- * One React app for every surface (sidebar overlay + settings section).
4
+ * One React app for every surface (settings section; the former sidebar
5
+ * full-page entry was retired — pair with dsh-settings-ui for room).
5
6
  * All interactive controls are host primitives (@deepseek-ai/dsh-client-ui-
6
7
  * primitives); all colors come from the ui-theme `--dsw-*` token layers so
7
8
  * light/dark follows the shell; all copy comes from the locale registry
@@ -64,15 +65,24 @@ const prim = (name) => P && P[name]
64
65
  let sessionsApi = null
65
66
  const sessionsSvc = () => sessionsApi
66
67
 
67
- // Composer services (inputTriggers + sessions) for the +技能 button: opens
68
- // the `skill` source (registered by dsh-client-ui-skill) as a menu. Absence
69
- // hides the button; nothing else depends on it.
68
+ // Composer services (inputTriggers + sessions) for the 技能 button plus
69
+ // the `connection` service for the picker's skill catalog: the button opens
70
+ // the plugin's own searchable picker popover (the host slash menu filters
71
+ // only by a typed query, which a button click cannot provide); the pick is
72
+ // written into the draft through the same scoped `slash/input-insert-text`
73
+ // event the host menu executes. Absence hides the button; nothing else
74
+ // depends on it.
70
75
  let composerScope = null
76
+ let connectionApi = null
71
77
 
72
78
  /**
73
79
  * Open one registered '/' source over a synthetic collapsed span appended at
74
80
  * the draft end (host toggleCommandMenu 同款调用形状;标准 kit 不暴露光标,
75
81
  * pick 依赖 span-CAS:点击后草稿若再变动则本次 pick 静默作废)。
82
+ *
83
+ * NOTE: the + 技能 button no longer uses this — the host menu cannot offer
84
+ * a search box, so the button opens SkillPicker instead. Kept for the
85
+ * contract tests and as the documented toggleSource path.
76
86
  */
77
87
  function openTriggerSource(scope, sessionId, input, sourceName) {
78
88
  const inputTriggers = scope && scope.inputTriggers
@@ -95,6 +105,127 @@ function openTriggerSource(scope, sessionId, input, sourceName) {
95
105
  return true
96
106
  }
97
107
 
108
+ /**
109
+ * Insert `text` at the end of the session draft through the same scoped
110
+ * event the host slash menu executes (`slash/input-insert-text`). The span
111
+ * CAS uses the freshest input snapshot handed to the slot props — while the
112
+ * picker popover is open the composer draft cannot move (focus is in the
113
+ * picker), so the splice applies; a stale snapshot quietly no-ops, same as
114
+ * the host menu's span-CAS.
115
+ */
116
+ function insertComposerText(scope, sessionId, input, text) {
117
+ const sessions = scope && scope.sessions
118
+ if (!sessions) return false
119
+ let actx
120
+ try { actx = sessions.scope(sessionId) } catch { return false }
121
+ if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
122
+ const draft = (input && input.draft) || ''
123
+ const at = draft.length
124
+ try {
125
+ return actx.bail(actx, 'slash/input-insert-text', {
126
+ text,
127
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
128
+ }) === true
129
+ } catch { return false }
130
+ }
131
+
132
+ /** Best-effort refocus of the composer textarea after the picker closes. */
133
+ function refocusComposer() {
134
+ try {
135
+ const card = document.querySelector('[data-composer-card]')
136
+ const ta = card && card.querySelector('textarea')
137
+ if (ta && typeof ta.focus === 'function') ta.focus()
138
+ } catch {}
139
+ }
140
+
141
+ /** Picker popover list cap — beyond this the search input is the filter. */
142
+ const PICKER_ROW_CAP = 200
143
+
144
+ /** Skill catalog cache for the picker (ui-skill 同源:connection.api.skills). */
145
+ let skillCatalog = { sessionId: null, at: 0, rows: null }
146
+ const SKILL_CATALOG_TTL = 60_000
147
+
148
+ /**
149
+ * Picker candidates from the host skill registry (the same list the `/`
150
+ * skill source shows). Subagent sessions have no catalog (ui-skill 同款守卫);
151
+ * a failed/absent connection rejects → the picker shows its empty state.
152
+ */
153
+ async function fetchSkillCandidates(connection, sessions, sessionId) {
154
+ try { if (sessions && typeof sessions.subagentAddress === 'function' && sessions.subagentAddress(sessionId) !== undefined) return [] } catch {}
155
+ const now = Date.now()
156
+ if (skillCatalog.rows !== null && skillCatalog.sessionId === sessionId && now - skillCatalog.at < SKILL_CATALOG_TTL) return skillCatalog.rows
157
+ const skills = connection && connection.api && connection.api.skills
158
+ if (!skills || typeof skills.list !== 'function') throw new Error('connection.api.skills unavailable')
159
+ const res = await skills.list({ sessionId })
160
+ const result = res && res.result
161
+ if (!result || result.ok !== true) throw new Error('skill.list failed')
162
+ const list = result.value && Array.isArray(result.value.skills) ? result.value.skills : []
163
+ const rows = list.map((s) => ({ name: s.name, description: s.description || '', modelInvocable: s.modelInvocable !== false }))
164
+ skillCatalog = { sessionId, at: now, rows }
165
+ return rows
166
+ }
167
+
168
+ /**
169
+ * + 技能 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
170
+ * 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
171
+ * 太多时无从筛选,所以浮层自带搜索框。键盘 ↑/↓/Enter/Esc,鼠标 hover+点击。
172
+ */
173
+ function SkillPicker(props) {
174
+ const t = props.t
175
+ const [query, setQuery] = useState('')
176
+ const [active, setActive] = useState(0)
177
+ const inputRef = useRef(null)
178
+ const listRef = useRef(null)
179
+ useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
180
+ useEffect(() => {
181
+ const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
182
+ try { document.addEventListener('keydown', onKey) } catch {}
183
+ return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
184
+ }, [])
185
+ const lower = query.trim().toLowerCase()
186
+ const all = props.rows || []
187
+ const filtered = lower === '' ? all : all.filter((r) => matchSkill(r, lower))
188
+ const shown = filtered.slice(0, PICKER_ROW_CAP)
189
+ useEffect(() => { setActive(0) }, [lower, props.rows])
190
+ useEffect(() => {
191
+ const list = listRef.current
192
+ const el = list && list.children[active]
193
+ if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
194
+ }, [active])
195
+ const onKeyDown = (e) => {
196
+ // IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
197
+ if (e.nativeEvent && e.nativeEvent.isComposing === true) return
198
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
199
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
200
+ else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
201
+ }
202
+ const width = 400
203
+ const winW = typeof window !== 'undefined' ? window.innerWidth : 800
204
+ const winH = typeof window !== 'undefined' ? window.innerHeight : 600
205
+ const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
206
+ const bottom = Math.max(8, winH - props.anchor.top + 6)
207
+ return h('div', { className: 'sk-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
208
+ h('div', { className: 'sk-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickSkillTitle') },
209
+ h('input', {
210
+ ref: inputRef, className: 'sk-input sk-picker-input', value: query,
211
+ placeholder: t('pickerSearch'), onChange: (e) => setQuery(e.target.value), onKeyDown,
212
+ }),
213
+ h('div', { className: 'sk-picker-list', ref: listRef, role: 'listbox' },
214
+ props.rows === null
215
+ ? h('div', { className: 'sk-picker-empty' }, t('pickerLoading'))
216
+ : shown.length === 0
217
+ ? h('div', { className: 'sk-picker-empty' }, t('emptySearch'))
218
+ : shown.map((row, i) => h('button', {
219
+ key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
220
+ className: 'sk-picker-row', 'data-active': i === active,
221
+ onMouseEnter: () => setActive(i),
222
+ onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
223
+ },
224
+ h('span', { className: 'sk-picker-name' }, row.name),
225
+ h('span', { className: 'sk-picker-desc' },
226
+ row.modelInvocable ? row.description : `${t('pickerUserOnly')} · ${row.description}`))))))
227
+ }
228
+
98
229
  // ── Locale ───────────────────────────────────────────────────────────────
99
230
 
100
231
  const NS = 'skillsManagement'
@@ -126,7 +257,7 @@ const ZH = {
126
257
  saved: '设置已保存',
127
258
  gitMissing: '未检测到 git',
128
259
  repoDirLabel: '本地目录(同步内容存放处)',
129
- tokenLabel: '访问令牌(私有仓库需要)',
260
+ tokenLabel: '访问令牌(分享到社区/私有仓库需要)',
130
261
  tokenConfigured: '已配置',
131
262
  clearToken: '清除',
132
263
  shareBtn: '分享',
@@ -204,8 +335,11 @@ const ZH = {
204
335
  invocationHint: '关闭后技能保留在库里,但不再注入对话目录(skill 工具也调不到)',
205
336
  pathLabel: '路径',
206
337
  meTag: '本机',
207
- pickSkill: '+技能',
338
+ pickSkill: '+ 技能',
208
339
  pickSkillTitle: '选择一个技能,其内容将注入本条消息',
340
+ pickerSearch: '搜索技能名称或描述…',
341
+ pickerLoading: '正在加载技能目录…',
342
+ pickerUserOnly: '仅用户',
209
343
  }
210
344
 
211
345
  const EN = {
@@ -235,7 +369,7 @@ const EN = {
235
369
  saved: 'Settings saved',
236
370
  gitMissing: 'git not found',
237
371
  repoDirLabel: 'Local directory (sync target)',
238
- tokenLabel: 'Access token (private repos)',
372
+ tokenLabel: 'Access token (community sharing / private repos)',
239
373
  tokenConfigured: 'configured',
240
374
  clearToken: 'Clear',
241
375
  shareBtn: 'Share',
@@ -315,6 +449,9 @@ const EN = {
315
449
  meTag: 'me',
316
450
  pickSkill: '+ Skill',
317
451
  pickSkillTitle: 'Pick a skill; its content is injected into this message',
452
+ pickerSearch: 'Search skills by name or description…',
453
+ pickerLoading: 'Loading skill catalog…',
454
+ pickerUserOnly: 'user-only',
318
455
  }
319
456
 
320
457
  // ── Pure helpers ────────────────────────────────────────────────────────
@@ -459,6 +596,15 @@ const STYLE = `<style>
459
596
  .sk-tabpill{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}
460
597
  .sk-chip{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family);font-size:12px;cursor:pointer;white-space:nowrap}
461
598
  .sk-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
599
+ .sk-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
600
+ .sk-picker{position:fixed;z-index:2147483201;width:400px;max-width:92vw;max-height:360px;display:flex;flex-direction:column;gap:4px;padding:6px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
601
+ .sk-picker-input{flex:none;box-sizing:border-box;width:100%}
602
+ .sk-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
603
+ .sk-picker-row{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;font-family:var(--dsw-font-family);font-size:13px}
604
+ .sk-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
605
+ .sk-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
606
+ .sk-picker-desc{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
607
+ .sk-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
462
608
  </style>`
463
609
 
464
610
  // ── Fetch layer ─────────────────────────────────────────────────────────
@@ -1249,61 +1395,16 @@ async function quickDelete(t, executor, name, done) {
1249
1395
 
1250
1396
  // ── Slot entries ─────────────────────────────────────────────────────────
1251
1397
 
1252
- function footerStyle() {
1253
- return { display: 'inline-flex', alignItems: 'center', gap: 6, margin: '4px 10px', padding: '8px 10px',
1254
- border: 'none', borderRadius: 8, background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
1255
- font: 'inherit', fontSize: 13, cursor: 'pointer', width: 'calc(100% - 20px)', textAlign: 'left' }
1256
- }
1257
-
1258
- /** Panel state lives OUTSIDE React: sidebar churn remounts slot entries,
1259
- * and any state kept in them (the old bug) is torn down with them. */
1260
- const panelStore = {
1261
- open: false,
1262
- listeners: new Set(),
1263
- set(v) { panelStore.open = v; for (const fn of panelStore.listeners) fn(v) },
1264
- subscribe(fn) { panelStore.listeners.add(fn); return () => panelStore.listeners.delete(fn) },
1265
- }
1266
-
1267
1398
  /** Jump to the run's conversation: open() is best-effort (it may reject
1268
- * after the selection lands), but folding our overlay must always happen. */
1399
+ * after the selection lands). */
1269
1400
  function openRunSession(sessionId) {
1270
1401
  try {
1271
1402
  const svc = sessionsSvc()
1272
1403
  if (svc && typeof svc.open === 'function') svc.open(sessionId)
1273
1404
  } catch {}
1274
- panelStore.set(false)
1275
1405
  return true
1276
1406
  }
1277
1407
 
1278
- /** Footer slot entry: the button, and — when open — the whole market page
1279
- * through the host primitives Modal (portal + overlay handled by the host's
1280
- * own React tree; no custom createRoot, which never commits here). */
1281
- function FooterSlotComponent(props) {
1282
- const [open, setOpen] = useState(panelStore.open)
1283
- useEffect(() => panelStore.subscribe(setOpen), [])
1284
- useEffect(ensureStyles, [])
1285
-
1286
- const t = props.__t
1287
- const labelText = t ? t('title') : 'Skills Market'
1288
- // The sidebar renders this entry with a `wide` owner prop: the collapsed
1289
- // rail passes false and shows the icon alone; expanded shows the label.
1290
- const wide = props.wide !== false
1291
- return h('span', { style: { display: 'contents' } },
1292
- h('button', { title: labelText, 'aria-label': labelText, onClick: () => panelStore.set(!panelStore.open),
1293
- style: footerStyle() },
1294
- P && P.IconSkillOutline16 ? h(P.IconSkillOutline16, { size: 16 }) : '\u{1F3AF}',
1295
- wide ? ' ' + labelText : ''),
1296
- open && (() => {
1297
- const page = h(SkillsPage, { t, embedded: false, onClose: () => panelStore.set(false) })
1298
- // Fullscreen: portal the fixed-position page to <body> so no sidebar
1299
- // ancestor (transform-containing or otherwise) can clip it.
1300
- if (RDP && typeof RDP.createPortal === 'function' && typeof document !== 'undefined') {
1301
- return RDP.createPortal(page, document.body)
1302
- }
1303
- return page // fallback: fixed positioning still applies from here
1304
- })())
1305
- }
1306
-
1307
1408
  /** Settings section slot entry: render the page directly in the host tree. */
1308
1409
  function SettingsSlotComponent(props) {
1309
1410
  useEffect(ensureStyles, [])
@@ -1317,7 +1418,7 @@ const CLIENT_NAME = '@weibaohui/skills-management'
1317
1418
  module.exports = {
1318
1419
  name: CLIENT_NAME,
1319
1420
  inject: ['slots', 'locale'],
1320
- __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource },
1421
+ __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource, insertComposerText, fetchSkillCandidates },
1321
1422
  /** Test/host helper: mount a standalone page into any container. */
1322
1423
  __boot(container, opts = {}) {
1323
1424
  ensureStyles()
@@ -1345,13 +1446,20 @@ module.exports = {
1345
1446
  })
1346
1447
  }
1347
1448
  } catch {}
1348
- // Composer services (inputTriggers + sessions) for the +技能 button;
1449
+ // Composer services (inputTriggers + sessions) for the 技能 button;
1349
1450
  // absence hides the button only.
1350
1451
  try {
1351
1452
  if (typeof ctx.inject === 'function') {
1352
1453
  ctx.inject(['inputTriggers', 'sessions'], (scope) => { composerScope = scope })
1353
1454
  }
1354
1455
  } catch {}
1456
+ // connection service for the picker skill catalog (host skill registry,
1457
+ // ui-skill 同源); absence keeps the button hidden.
1458
+ try {
1459
+ if (typeof ctx.inject === 'function') {
1460
+ ctx.inject(['connection'], (scope) => { connectionApi = scope && scope.connection })
1461
+ }
1462
+ } catch {}
1355
1463
  // Locale service is optional at boot order — degrade to EN until present
1356
1464
  let t = (key, vars) => {
1357
1465
  let out = EN[key] ?? key
@@ -1373,21 +1481,6 @@ module.exports = {
1373
1481
  }
1374
1482
  }
1375
1483
  } catch (e) { try { console.error('[skills-management] locale init:', e) } catch {} }
1376
- ctx.effect(() => {
1377
- try {
1378
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
1379
- name: 'sidebar.footer.action',
1380
- id: CLIENT_NAME,
1381
- order: 50,
1382
- locale: NS,
1383
- label: () => t('title'),
1384
- inject: () => ({ t }),
1385
- }, function FooterSlot(apiProps) {
1386
- // ownerProps (the sidebar's wide flag) land here — forward them
1387
- return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
1388
- }))
1389
- } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('footer:' + (e && e.message)); throw e }
1390
- }, 'skills-management: sidebar footer action')
1391
1484
  ctx.effect(() => {
1392
1485
  try {
1393
1486
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -1414,7 +1507,7 @@ module.exports = {
1414
1507
  inject: () => ({ t }),
1415
1508
  }, function SkillButtonSlot(apiProps) {
1416
1509
  return h(ComposerButtonSlot, {
1417
- __t: t, icon: '⚡', label: t('pickSkill'), title: t('pickSkillTitle'),
1510
+ __t: t, label: t('pickSkill'), title: t('pickSkillTitle'),
1418
1511
  source: 'skill', sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
1419
1512
  })
1420
1513
  }))
@@ -1423,16 +1516,47 @@ module.exports = {
1423
1516
  },
1424
1517
  }
1425
1518
 
1426
- /** Composer tool-row button: opens a registered '/' source (ui-skill's
1427
- * `skill` source) over the session's trigger controller. Hidden while the
1428
- * inputTriggers/sessions services are absent. */
1519
+ /** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
1520
+ * 技能 picker 浮层(候选 = 宿主技能注册表,ui-skill 同源);pick
1521
+ * slash/input-insert-text 写入 `/<name> `。浮层背板盖住按钮以外的区域,
1522
+ * 再点一次按钮会先落在背板上——天然形成开关切换。
1523
+ * connection 缺席(picker 无目录来源)时回退旧的 toggleSource 宿主菜单;
1524
+ * inputTriggers/sessions 缺席时按钮隐藏(与旧行为一致)。 */
1429
1525
  function ComposerButtonSlot(props) {
1430
1526
  useEffect(ensureStyles, [])
1527
+ const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
1528
+ const [rows, setRows] = useState(null) // null = 加载中
1529
+ const btnRef = useRef(null)
1530
+ const liveInput = useRef(props.input)
1531
+ liveInput.current = props.input
1431
1532
  const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
1432
1533
  if (!ready) return null
1534
+ const close = () => setPicker(null)
1535
+ const open = () => {
1536
+ // 目录来源缺席 → 退回宿主斜杠菜单(无搜索,但按钮不消失)
1537
+ if (!connectionApi) { openTriggerSource(composerScope, props.sessionId, liveInput.current, props.source); return }
1538
+ let anchor = { left: 16, top: 160 }
1539
+ try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
1540
+ setPicker({ left: anchor.left, top: anchor.top })
1541
+ setRows(null)
1542
+ fetchSkillCandidates(connectionApi, composerScope.sessions, props.sessionId)
1543
+ .then((list) => setRows(Array.isArray(list) ? list : []))
1544
+ .catch(() => setRows([]))
1545
+ }
1546
+ const pick = (row) => {
1547
+ insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
1548
+ close()
1549
+ refocusComposer()
1550
+ }
1551
+ const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
1552
+ ? RDP.createPortal(h(SkillPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
1553
+ : null
1433
1554
  return h('button', {
1434
1555
  className: 'sk-chip',
1556
+ ref: btnRef,
1435
1557
  title: props.title || props.label,
1436
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
1437
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
1558
+ 'aria-haspopup': 'dialog',
1559
+ 'aria-expanded': picker !== null,
1560
+ onClick: open,
1561
+ }, props.label, popover)
1438
1562
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weibaohui/skills-management",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "dsh 插件 · 技能市场:一个页面管理本机所有 coding agent 的技能(Claude、ZCode、Codex 等十余个执行器),一键收编进 DSH 用户库供 skill 工具调用;内置 6600+ ntd 技能市场可浏览搜索安装,支持模型可见性治理与市场每日自动同步。",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/index.js CHANGED
@@ -65,6 +65,7 @@ const EXECUTOR_DEFS = [
65
65
  { key: 'pi', label: 'Pi', sub: '.pi/skills' },
66
66
  { key: 'mimo', label: 'Mimo', sub: '.local/share/mimocode/skills' },
67
67
  { key: 'zhanlu', label: 'ZhanLu', sub: '.local/share/zhanlu/skills' },
68
+ { key: 'workbuddy', label: 'WorkBuddy', sub: '.workbuddy/skills' },
68
69
  // agents 共享池曾是只读来源;治理键开关(disable-model-invocation)覆盖该根后
69
70
  // "只读"名不副实——与 dsh 的 user-agents 内置根对齐,按普通可写来源对待。
70
71
  { key: 'agents', label: 'Agents', sub: '.agents/skills' },
@@ -223,13 +224,36 @@ function validSkillName(name) {
223
224
  return typeof name === 'string' && name !== '' && !name.includes('..') && !name.includes('\\') && !name.startsWith('/')
224
225
  }
225
226
 
226
- /** Resolve `<root>/<name>` to an existing skill dir under one source root. */
227
- async function findDirUnderRoot(root, fullName, where) {
228
- try { return await resolveSkillDir(root, fullName) }
227
+ /**
228
+ * Resolve a skill dir under one source root by the identifier the client sends.
229
+ * Tries the direct path `<root>/<name>` first (covers dir name == frontmatter
230
+ * name, and nested relPaths like `grouped/foo`). On ENOENT, falls back to a
231
+ * frontmatter-name scan: some sources ship a skill in a dir whose name ≠ its
232
+ * frontmatter `name` (e.g. WorkBuddy's `dev-expert__skillhub/` whose frontmatter
233
+ * `name` is `dev-expert`). The client lists & addresses such skills by their
234
+ * frontmatter `name`, so the resolver must honor it. frontmatter `name` is always
235
+ * single-segment kebab, so a slash-bearing request is a relPath that already
236
+ * missed direct lookup and cannot be a frontmatter name — skip the scan.
237
+ * Returns the skill dir or undefined.
238
+ */
239
+ async function resolveSkillDirByName(root, name) {
240
+ try { return await resolveSkillDir(root, name) }
229
241
  catch (e) {
230
- if (String(e && e.message).includes('not found')) throw new Error(`skill '${fullName}' not found in ${where}`)
231
- throw e
242
+ if (!String(e && e.message).includes('not found')) throw e
243
+ }
244
+ if (name.includes('/')) return undefined
245
+ for (const entry of await scanRoot(root)) {
246
+ try { if ((await readSkillEntry(entry)).name === name) return entry.dir }
247
+ catch {}
232
248
  }
249
+ return undefined
250
+ }
251
+
252
+ /** Resolve `<root>/<name>` to an existing skill dir under one source root. */
253
+ async function findDirUnderRoot(root, fullName, where) {
254
+ const dir = await resolveSkillDirByName(root, fullName)
255
+ if (dir === undefined) throw new Error(`skill '${fullName}' not found in ${where}`)
256
+ return dir
233
257
  }
234
258
 
235
259
  async function sendSkillFile(res, skillDir, relPath, contentType) {
@@ -722,9 +746,8 @@ module.exports = {
722
746
  const row = findExecutorRow(key)
723
747
  if (row === undefined) throw new Error(`unknown executor '${key}'`)
724
748
  if (row.readOnly) throw new Error(`source '${key}' is read-only; cannot delete skills there`)
725
- const target = join(row.root, name)
726
- const stat = await fsP.stat(target).catch(() => undefined)
727
- if (stat === undefined || !stat.isDirectory()) throw new Error(`skill '${name}' not found in ${row.label} (${row.key})`)
749
+ const target = await resolveSkillDirByName(row.root, name)
750
+ if (target === undefined) throw new Error(`skill '${name}' not found in ${row.label} (${row.key})`)
728
751
  await fsP.rm(target, { recursive: true })
729
752
  if (key === 'dsh') invalidate()
730
753
  return { removed: name, executor: key }