@weibaohui/skills-management 0.1.5 → 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 +8 -2
- package/client/bundle.js +264 -65
- package/client/index.js +264 -65
- package/package.json +1 -1
- package/src/index.js +59 -12
package/README.md
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
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 工具行新增「+ 技能」按钮,弹出带搜索框的技能候选浮层(候选 = 宿主技能注册表,与 `/` 菜单同源;支持键盘 ↑/↓/Enter/Esc),选中即把 `/技能名` 写入草稿,发送时技能内容注入该条消息
|
|
16
17
|
- **市场自动同步**:市场仓库自动克隆与每日更新(可关),支持 GitCode 私有仓库 access token
|
|
18
|
+
- **稀疏检出**:ntd-resource 仓库同时携带专家/模板等子树,市场只检出 `skills` 子目录(git partial clone + sparse-checkout),省一半以上流量与磁盘;已有全量检出会在下次同步时原地转换
|
|
17
19
|
- **软链布局兼容**:各执行器目录间软链共享的技能不会重复展示
|
|
18
20
|
|
|
19
21
|
## 安装
|
|
@@ -26,7 +28,11 @@ dsh plugin --profile web add @weibaohui/skills-management -w
|
|
|
26
28
|
|
|
27
29
|
## 使用
|
|
28
30
|
|
|
29
|
-
1. 打开 Web UI →
|
|
31
|
+
1. 打开 Web UI → **设置** → 左侧「技能市场」section 即完整管理页(可搭配 dsh-settings-ui 插件把设置窗口调大/全屏)
|
|
30
32
|
2. 「已安装」视图管理本机技能;「市场」视图浏览/搜索/安装 ntd 合集技能;「执行器」视图按来源钻入查看各 coding agent 的技能
|
|
31
33
|
3. 详情页可预览 SKILL.md 全文、安装到用户库、切换模型可调用开关
|
|
32
34
|
4. ⚙ 设置面板里可配置市场仓库地址、分支、access token 与自动同步
|
|
35
|
+
|
|
36
|
+
## 联系我 :飞书群
|
|
37
|
+
|
|
38
|
+

|
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 (
|
|
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,6 +75,167 @@ window.__ModuleLoader__.load({
|
|
|
74
75
|
let sessionsApi = null
|
|
75
76
|
const sessionsSvc = () => sessionsApi
|
|
76
77
|
|
|
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.
|
|
85
|
+
let composerScope = null
|
|
86
|
+
let connectionApi = null
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Open one registered '/' source over a synthetic collapsed span appended at
|
|
90
|
+
* the draft end (host toggleCommandMenu 同款调用形状;标准 kit 不暴露光标,
|
|
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.
|
|
96
|
+
*/
|
|
97
|
+
function openTriggerSource(scope, sessionId, input, sourceName) {
|
|
98
|
+
const inputTriggers = scope && scope.inputTriggers
|
|
99
|
+
const sessions = scope && scope.sessions
|
|
100
|
+
if (!inputTriggers || !sessions) return false
|
|
101
|
+
let actx
|
|
102
|
+
try { actx = sessions.scope(sessionId) } catch { return false }
|
|
103
|
+
if (actx === undefined || actx === null) return false
|
|
104
|
+
let controller
|
|
105
|
+
try { controller = inputTriggers.sessionOf(actx) } catch { return false }
|
|
106
|
+
const draft = (input && input.draft) || ''
|
|
107
|
+
const at = draft.length
|
|
108
|
+
controller.toggleSource(sourceName, {
|
|
109
|
+
trigger: '/',
|
|
110
|
+
query: '',
|
|
111
|
+
quoted: false,
|
|
112
|
+
position: draft.trim() === '' ? 'leading' : 'inline',
|
|
113
|
+
span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
|
|
114
|
+
})
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
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
|
+
|
|
77
239
|
// ── Locale ───────────────────────────────────────────────────────────────
|
|
78
240
|
|
|
79
241
|
const NS = 'skillsManagement'
|
|
@@ -105,7 +267,7 @@ window.__ModuleLoader__.load({
|
|
|
105
267
|
saved: '设置已保存',
|
|
106
268
|
gitMissing: '未检测到 git',
|
|
107
269
|
repoDirLabel: '本地目录(同步内容存放处)',
|
|
108
|
-
tokenLabel: '
|
|
270
|
+
tokenLabel: '访问令牌(分享到社区/私有仓库需要)',
|
|
109
271
|
tokenConfigured: '已配置',
|
|
110
272
|
clearToken: '清除',
|
|
111
273
|
shareBtn: '分享',
|
|
@@ -183,6 +345,11 @@ window.__ModuleLoader__.load({
|
|
|
183
345
|
invocationHint: '关闭后技能保留在库里,但不再注入对话目录(skill 工具也调不到)',
|
|
184
346
|
pathLabel: '路径',
|
|
185
347
|
meTag: '本机',
|
|
348
|
+
pickSkill: '+ 技能',
|
|
349
|
+
pickSkillTitle: '选择一个技能,其内容将注入本条消息',
|
|
350
|
+
pickerSearch: '搜索技能名称或描述…',
|
|
351
|
+
pickerLoading: '正在加载技能目录…',
|
|
352
|
+
pickerUserOnly: '仅用户',
|
|
186
353
|
}
|
|
187
354
|
|
|
188
355
|
const EN = {
|
|
@@ -212,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
212
379
|
saved: 'Settings saved',
|
|
213
380
|
gitMissing: 'git not found',
|
|
214
381
|
repoDirLabel: 'Local directory (sync target)',
|
|
215
|
-
tokenLabel: 'Access token (private repos)',
|
|
382
|
+
tokenLabel: 'Access token (community sharing / private repos)',
|
|
216
383
|
tokenConfigured: 'configured',
|
|
217
384
|
clearToken: 'Clear',
|
|
218
385
|
shareBtn: 'Share',
|
|
@@ -290,6 +457,11 @@ window.__ModuleLoader__.load({
|
|
|
290
457
|
invocationHint: 'When off, the skill stays in the library but is not injected into conversation catalogs',
|
|
291
458
|
pathLabel: 'Path',
|
|
292
459
|
meTag: 'me',
|
|
460
|
+
pickSkill: '+ Skill',
|
|
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',
|
|
293
465
|
}
|
|
294
466
|
|
|
295
467
|
// ── Pure helpers ────────────────────────────────────────────────────────
|
|
@@ -432,6 +604,17 @@ window.__ModuleLoader__.load({
|
|
|
432
604
|
.sk-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
433
605
|
.sk-menu-item.on{color:var(--dsw-alias-state-business-primary);font-weight:500}
|
|
434
606
|
.sk-tabpill{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}
|
|
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}
|
|
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}
|
|
435
618
|
</style>`
|
|
436
619
|
|
|
437
620
|
// ── Fetch layer ─────────────────────────────────────────────────────────
|
|
@@ -1222,61 +1405,16 @@ window.__ModuleLoader__.load({
|
|
|
1222
1405
|
|
|
1223
1406
|
// ── Slot entries ─────────────────────────────────────────────────────────
|
|
1224
1407
|
|
|
1225
|
-
function footerStyle() {
|
|
1226
|
-
return { display: 'inline-flex', alignItems: 'center', gap: 6, margin: '4px 10px', padding: '8px 10px',
|
|
1227
|
-
border: 'none', borderRadius: 8, background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
|
|
1228
|
-
font: 'inherit', fontSize: 13, cursor: 'pointer', width: 'calc(100% - 20px)', textAlign: 'left' }
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
/** Panel state lives OUTSIDE React: sidebar churn remounts slot entries,
|
|
1232
|
-
* and any state kept in them (the old bug) is torn down with them. */
|
|
1233
|
-
const panelStore = {
|
|
1234
|
-
open: false,
|
|
1235
|
-
listeners: new Set(),
|
|
1236
|
-
set(v) { panelStore.open = v; for (const fn of panelStore.listeners) fn(v) },
|
|
1237
|
-
subscribe(fn) { panelStore.listeners.add(fn); return () => panelStore.listeners.delete(fn) },
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
1408
|
/** Jump to the run's conversation: open() is best-effort (it may reject
|
|
1241
|
-
* after the selection lands)
|
|
1409
|
+
* after the selection lands). */
|
|
1242
1410
|
function openRunSession(sessionId) {
|
|
1243
1411
|
try {
|
|
1244
1412
|
const svc = sessionsSvc()
|
|
1245
1413
|
if (svc && typeof svc.open === 'function') svc.open(sessionId)
|
|
1246
1414
|
} catch {}
|
|
1247
|
-
panelStore.set(false)
|
|
1248
1415
|
return true
|
|
1249
1416
|
}
|
|
1250
1417
|
|
|
1251
|
-
/** Footer slot entry: the button, and — when open — the whole market page
|
|
1252
|
-
* through the host primitives Modal (portal + overlay handled by the host's
|
|
1253
|
-
* own React tree; no custom createRoot, which never commits here). */
|
|
1254
|
-
function FooterSlotComponent(props) {
|
|
1255
|
-
const [open, setOpen] = useState(panelStore.open)
|
|
1256
|
-
useEffect(() => panelStore.subscribe(setOpen), [])
|
|
1257
|
-
useEffect(ensureStyles, [])
|
|
1258
|
-
|
|
1259
|
-
const t = props.__t
|
|
1260
|
-
const labelText = t ? t('title') : 'Skills Market'
|
|
1261
|
-
// The sidebar renders this entry with a `wide` owner prop: the collapsed
|
|
1262
|
-
// rail passes false and shows the icon alone; expanded shows the label.
|
|
1263
|
-
const wide = props.wide !== false
|
|
1264
|
-
return h('span', { style: { display: 'contents' } },
|
|
1265
|
-
h('button', { title: labelText, 'aria-label': labelText, onClick: () => panelStore.set(!panelStore.open),
|
|
1266
|
-
style: footerStyle() },
|
|
1267
|
-
P && P.IconSkillOutline16 ? h(P.IconSkillOutline16, { size: 16 }) : '\u{1F3AF}',
|
|
1268
|
-
wide ? ' ' + labelText : ''),
|
|
1269
|
-
open && (() => {
|
|
1270
|
-
const page = h(SkillsPage, { t, embedded: false, onClose: () => panelStore.set(false) })
|
|
1271
|
-
// Fullscreen: portal the fixed-position page to <body> so no sidebar
|
|
1272
|
-
// ancestor (transform-containing or otherwise) can clip it.
|
|
1273
|
-
if (RDP && typeof RDP.createPortal === 'function' && typeof document !== 'undefined') {
|
|
1274
|
-
return RDP.createPortal(page, document.body)
|
|
1275
|
-
}
|
|
1276
|
-
return page // fallback: fixed positioning still applies from here
|
|
1277
|
-
})())
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
1418
|
/** Settings section slot entry: render the page directly in the host tree. */
|
|
1281
1419
|
function SettingsSlotComponent(props) {
|
|
1282
1420
|
useEffect(ensureStyles, [])
|
|
@@ -1290,7 +1428,7 @@ window.__ModuleLoader__.load({
|
|
|
1290
1428
|
module.exports = {
|
|
1291
1429
|
name: CLIENT_NAME,
|
|
1292
1430
|
inject: ['slots', 'locale'],
|
|
1293
|
-
__internals: { NS, ZH, EN, matchSkill, formatSize, formatTime },
|
|
1431
|
+
__internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource, insertComposerText, fetchSkillCandidates },
|
|
1294
1432
|
/** Test/host helper: mount a standalone page into any container. */
|
|
1295
1433
|
__boot(container, opts = {}) {
|
|
1296
1434
|
ensureStyles()
|
|
@@ -1318,6 +1456,20 @@ window.__ModuleLoader__.load({
|
|
|
1318
1456
|
})
|
|
1319
1457
|
}
|
|
1320
1458
|
} catch {}
|
|
1459
|
+
// Composer services (inputTriggers + sessions) for the + 技能 button;
|
|
1460
|
+
// absence hides the button only.
|
|
1461
|
+
try {
|
|
1462
|
+
if (typeof ctx.inject === 'function') {
|
|
1463
|
+
ctx.inject(['inputTriggers', 'sessions'], (scope) => { composerScope = scope })
|
|
1464
|
+
}
|
|
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 {}
|
|
1321
1473
|
// Locale service is optional at boot order — degrade to EN until present
|
|
1322
1474
|
let t = (key, vars) => {
|
|
1323
1475
|
let out = EN[key] ?? key
|
|
@@ -1339,21 +1491,6 @@ window.__ModuleLoader__.load({
|
|
|
1339
1491
|
}
|
|
1340
1492
|
}
|
|
1341
1493
|
} catch (e) { try { console.error('[skills-management] locale init:', e) } catch {} }
|
|
1342
|
-
ctx.effect(() => {
|
|
1343
|
-
try {
|
|
1344
|
-
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
1345
|
-
name: 'sidebar.footer.action',
|
|
1346
|
-
id: CLIENT_NAME,
|
|
1347
|
-
order: 50,
|
|
1348
|
-
locale: NS,
|
|
1349
|
-
label: () => t('title'),
|
|
1350
|
-
inject: () => ({ t }),
|
|
1351
|
-
}, function FooterSlot(apiProps) {
|
|
1352
|
-
// ownerProps (the sidebar's wide flag) land here — forward them
|
|
1353
|
-
return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
|
|
1354
|
-
}))
|
|
1355
|
-
} catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('footer:' + (e && e.message)); throw e }
|
|
1356
|
-
}, 'skills-management: sidebar footer action')
|
|
1357
1494
|
ctx.effect(() => {
|
|
1358
1495
|
try {
|
|
1359
1496
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
@@ -1369,9 +1506,71 @@ window.__ModuleLoader__.load({
|
|
|
1369
1506
|
}))
|
|
1370
1507
|
} catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('settings:' + (e && e.message)); throw e }
|
|
1371
1508
|
}, 'skills-management: settings section')
|
|
1509
|
+
ctx.effect(() => {
|
|
1510
|
+
try {
|
|
1511
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
1512
|
+
name: 'conversation.input.left',
|
|
1513
|
+
id: CLIENT_NAME,
|
|
1514
|
+
order: 60,
|
|
1515
|
+
locale: NS,
|
|
1516
|
+
label: () => t('pickSkill'),
|
|
1517
|
+
inject: () => ({ t }),
|
|
1518
|
+
}, function SkillButtonSlot(apiProps) {
|
|
1519
|
+
return h(ComposerButtonSlot, {
|
|
1520
|
+
__t: t, label: t('pickSkill'), title: t('pickSkillTitle'),
|
|
1521
|
+
source: 'skill', sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
|
|
1522
|
+
})
|
|
1523
|
+
}))
|
|
1524
|
+
} catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('input.left:' + (e && e.message)); throw e }
|
|
1525
|
+
}, 'skills-management: input left button')
|
|
1372
1526
|
},
|
|
1373
1527
|
}
|
|
1374
1528
|
|
|
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 缺席时按钮隐藏(与旧行为一致)。 */
|
|
1535
|
+
function ComposerButtonSlot(props) {
|
|
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
|
|
1542
|
+
const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
|
|
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
|
|
1564
|
+
return h('button', {
|
|
1565
|
+
className: 'sk-chip',
|
|
1566
|
+
ref: btnRef,
|
|
1567
|
+
title: props.title || props.label,
|
|
1568
|
+
'aria-haspopup': 'dialog',
|
|
1569
|
+
'aria-expanded': picker !== null,
|
|
1570
|
+
onClick: open,
|
|
1571
|
+
}, props.label, popover)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1375
1574
|
return module.exports
|
|
1376
1575
|
}
|
|
1377
1576
|
})
|
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 (
|
|
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,6 +65,167 @@ const prim = (name) => P && P[name]
|
|
|
64
65
|
let sessionsApi = null
|
|
65
66
|
const sessionsSvc = () => sessionsApi
|
|
66
67
|
|
|
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.
|
|
75
|
+
let composerScope = null
|
|
76
|
+
let connectionApi = null
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open one registered '/' source over a synthetic collapsed span appended at
|
|
80
|
+
* the draft end (host toggleCommandMenu 同款调用形状;标准 kit 不暴露光标,
|
|
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.
|
|
86
|
+
*/
|
|
87
|
+
function openTriggerSource(scope, sessionId, input, sourceName) {
|
|
88
|
+
const inputTriggers = scope && scope.inputTriggers
|
|
89
|
+
const sessions = scope && scope.sessions
|
|
90
|
+
if (!inputTriggers || !sessions) return false
|
|
91
|
+
let actx
|
|
92
|
+
try { actx = sessions.scope(sessionId) } catch { return false }
|
|
93
|
+
if (actx === undefined || actx === null) return false
|
|
94
|
+
let controller
|
|
95
|
+
try { controller = inputTriggers.sessionOf(actx) } catch { return false }
|
|
96
|
+
const draft = (input && input.draft) || ''
|
|
97
|
+
const at = draft.length
|
|
98
|
+
controller.toggleSource(sourceName, {
|
|
99
|
+
trigger: '/',
|
|
100
|
+
query: '',
|
|
101
|
+
quoted: false,
|
|
102
|
+
position: draft.trim() === '' ? 'leading' : 'inline',
|
|
103
|
+
span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
|
|
104
|
+
})
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
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
|
+
|
|
67
229
|
// ── Locale ───────────────────────────────────────────────────────────────
|
|
68
230
|
|
|
69
231
|
const NS = 'skillsManagement'
|
|
@@ -95,7 +257,7 @@ const ZH = {
|
|
|
95
257
|
saved: '设置已保存',
|
|
96
258
|
gitMissing: '未检测到 git',
|
|
97
259
|
repoDirLabel: '本地目录(同步内容存放处)',
|
|
98
|
-
tokenLabel: '
|
|
260
|
+
tokenLabel: '访问令牌(分享到社区/私有仓库需要)',
|
|
99
261
|
tokenConfigured: '已配置',
|
|
100
262
|
clearToken: '清除',
|
|
101
263
|
shareBtn: '分享',
|
|
@@ -173,6 +335,11 @@ const ZH = {
|
|
|
173
335
|
invocationHint: '关闭后技能保留在库里,但不再注入对话目录(skill 工具也调不到)',
|
|
174
336
|
pathLabel: '路径',
|
|
175
337
|
meTag: '本机',
|
|
338
|
+
pickSkill: '+ 技能',
|
|
339
|
+
pickSkillTitle: '选择一个技能,其内容将注入本条消息',
|
|
340
|
+
pickerSearch: '搜索技能名称或描述…',
|
|
341
|
+
pickerLoading: '正在加载技能目录…',
|
|
342
|
+
pickerUserOnly: '仅用户',
|
|
176
343
|
}
|
|
177
344
|
|
|
178
345
|
const EN = {
|
|
@@ -202,7 +369,7 @@ const EN = {
|
|
|
202
369
|
saved: 'Settings saved',
|
|
203
370
|
gitMissing: 'git not found',
|
|
204
371
|
repoDirLabel: 'Local directory (sync target)',
|
|
205
|
-
tokenLabel: 'Access token (private repos)',
|
|
372
|
+
tokenLabel: 'Access token (community sharing / private repos)',
|
|
206
373
|
tokenConfigured: 'configured',
|
|
207
374
|
clearToken: 'Clear',
|
|
208
375
|
shareBtn: 'Share',
|
|
@@ -280,6 +447,11 @@ const EN = {
|
|
|
280
447
|
invocationHint: 'When off, the skill stays in the library but is not injected into conversation catalogs',
|
|
281
448
|
pathLabel: 'Path',
|
|
282
449
|
meTag: 'me',
|
|
450
|
+
pickSkill: '+ Skill',
|
|
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',
|
|
283
455
|
}
|
|
284
456
|
|
|
285
457
|
// ── Pure helpers ────────────────────────────────────────────────────────
|
|
@@ -422,6 +594,17 @@ const STYLE = `<style>
|
|
|
422
594
|
.sk-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
423
595
|
.sk-menu-item.on{color:var(--dsw-alias-state-business-primary);font-weight:500}
|
|
424
596
|
.sk-tabpill{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}
|
|
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}
|
|
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}
|
|
425
608
|
</style>`
|
|
426
609
|
|
|
427
610
|
// ── Fetch layer ─────────────────────────────────────────────────────────
|
|
@@ -1212,61 +1395,16 @@ async function quickDelete(t, executor, name, done) {
|
|
|
1212
1395
|
|
|
1213
1396
|
// ── Slot entries ─────────────────────────────────────────────────────────
|
|
1214
1397
|
|
|
1215
|
-
function footerStyle() {
|
|
1216
|
-
return { display: 'inline-flex', alignItems: 'center', gap: 6, margin: '4px 10px', padding: '8px 10px',
|
|
1217
|
-
border: 'none', borderRadius: 8, background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
|
|
1218
|
-
font: 'inherit', fontSize: 13, cursor: 'pointer', width: 'calc(100% - 20px)', textAlign: 'left' }
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
/** Panel state lives OUTSIDE React: sidebar churn remounts slot entries,
|
|
1222
|
-
* and any state kept in them (the old bug) is torn down with them. */
|
|
1223
|
-
const panelStore = {
|
|
1224
|
-
open: false,
|
|
1225
|
-
listeners: new Set(),
|
|
1226
|
-
set(v) { panelStore.open = v; for (const fn of panelStore.listeners) fn(v) },
|
|
1227
|
-
subscribe(fn) { panelStore.listeners.add(fn); return () => panelStore.listeners.delete(fn) },
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
1398
|
/** Jump to the run's conversation: open() is best-effort (it may reject
|
|
1231
|
-
* after the selection lands)
|
|
1399
|
+
* after the selection lands). */
|
|
1232
1400
|
function openRunSession(sessionId) {
|
|
1233
1401
|
try {
|
|
1234
1402
|
const svc = sessionsSvc()
|
|
1235
1403
|
if (svc && typeof svc.open === 'function') svc.open(sessionId)
|
|
1236
1404
|
} catch {}
|
|
1237
|
-
panelStore.set(false)
|
|
1238
1405
|
return true
|
|
1239
1406
|
}
|
|
1240
1407
|
|
|
1241
|
-
/** Footer slot entry: the button, and — when open — the whole market page
|
|
1242
|
-
* through the host primitives Modal (portal + overlay handled by the host's
|
|
1243
|
-
* own React tree; no custom createRoot, which never commits here). */
|
|
1244
|
-
function FooterSlotComponent(props) {
|
|
1245
|
-
const [open, setOpen] = useState(panelStore.open)
|
|
1246
|
-
useEffect(() => panelStore.subscribe(setOpen), [])
|
|
1247
|
-
useEffect(ensureStyles, [])
|
|
1248
|
-
|
|
1249
|
-
const t = props.__t
|
|
1250
|
-
const labelText = t ? t('title') : 'Skills Market'
|
|
1251
|
-
// The sidebar renders this entry with a `wide` owner prop: the collapsed
|
|
1252
|
-
// rail passes false and shows the icon alone; expanded shows the label.
|
|
1253
|
-
const wide = props.wide !== false
|
|
1254
|
-
return h('span', { style: { display: 'contents' } },
|
|
1255
|
-
h('button', { title: labelText, 'aria-label': labelText, onClick: () => panelStore.set(!panelStore.open),
|
|
1256
|
-
style: footerStyle() },
|
|
1257
|
-
P && P.IconSkillOutline16 ? h(P.IconSkillOutline16, { size: 16 }) : '\u{1F3AF}',
|
|
1258
|
-
wide ? ' ' + labelText : ''),
|
|
1259
|
-
open && (() => {
|
|
1260
|
-
const page = h(SkillsPage, { t, embedded: false, onClose: () => panelStore.set(false) })
|
|
1261
|
-
// Fullscreen: portal the fixed-position page to <body> so no sidebar
|
|
1262
|
-
// ancestor (transform-containing or otherwise) can clip it.
|
|
1263
|
-
if (RDP && typeof RDP.createPortal === 'function' && typeof document !== 'undefined') {
|
|
1264
|
-
return RDP.createPortal(page, document.body)
|
|
1265
|
-
}
|
|
1266
|
-
return page // fallback: fixed positioning still applies from here
|
|
1267
|
-
})())
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
1408
|
/** Settings section slot entry: render the page directly in the host tree. */
|
|
1271
1409
|
function SettingsSlotComponent(props) {
|
|
1272
1410
|
useEffect(ensureStyles, [])
|
|
@@ -1280,7 +1418,7 @@ const CLIENT_NAME = '@weibaohui/skills-management'
|
|
|
1280
1418
|
module.exports = {
|
|
1281
1419
|
name: CLIENT_NAME,
|
|
1282
1420
|
inject: ['slots', 'locale'],
|
|
1283
|
-
__internals: { NS, ZH, EN, matchSkill, formatSize, formatTime },
|
|
1421
|
+
__internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource, insertComposerText, fetchSkillCandidates },
|
|
1284
1422
|
/** Test/host helper: mount a standalone page into any container. */
|
|
1285
1423
|
__boot(container, opts = {}) {
|
|
1286
1424
|
ensureStyles()
|
|
@@ -1308,6 +1446,20 @@ module.exports = {
|
|
|
1308
1446
|
})
|
|
1309
1447
|
}
|
|
1310
1448
|
} catch {}
|
|
1449
|
+
// Composer services (inputTriggers + sessions) for the + 技能 button;
|
|
1450
|
+
// absence hides the button only.
|
|
1451
|
+
try {
|
|
1452
|
+
if (typeof ctx.inject === 'function') {
|
|
1453
|
+
ctx.inject(['inputTriggers', 'sessions'], (scope) => { composerScope = scope })
|
|
1454
|
+
}
|
|
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 {}
|
|
1311
1463
|
// Locale service is optional at boot order — degrade to EN until present
|
|
1312
1464
|
let t = (key, vars) => {
|
|
1313
1465
|
let out = EN[key] ?? key
|
|
@@ -1329,21 +1481,6 @@ module.exports = {
|
|
|
1329
1481
|
}
|
|
1330
1482
|
}
|
|
1331
1483
|
} catch (e) { try { console.error('[skills-management] locale init:', e) } catch {} }
|
|
1332
|
-
ctx.effect(() => {
|
|
1333
|
-
try {
|
|
1334
|
-
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
1335
|
-
name: 'sidebar.footer.action',
|
|
1336
|
-
id: CLIENT_NAME,
|
|
1337
|
-
order: 50,
|
|
1338
|
-
locale: NS,
|
|
1339
|
-
label: () => t('title'),
|
|
1340
|
-
inject: () => ({ t }),
|
|
1341
|
-
}, function FooterSlot(apiProps) {
|
|
1342
|
-
// ownerProps (the sidebar's wide flag) land here — forward them
|
|
1343
|
-
return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
|
|
1344
|
-
}))
|
|
1345
|
-
} catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('footer:' + (e && e.message)); throw e }
|
|
1346
|
-
}, 'skills-management: sidebar footer action')
|
|
1347
1484
|
ctx.effect(() => {
|
|
1348
1485
|
try {
|
|
1349
1486
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
@@ -1359,5 +1496,67 @@ module.exports = {
|
|
|
1359
1496
|
}))
|
|
1360
1497
|
} catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('settings:' + (e && e.message)); throw e }
|
|
1361
1498
|
}, 'skills-management: settings section')
|
|
1499
|
+
ctx.effect(() => {
|
|
1500
|
+
try {
|
|
1501
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
1502
|
+
name: 'conversation.input.left',
|
|
1503
|
+
id: CLIENT_NAME,
|
|
1504
|
+
order: 60,
|
|
1505
|
+
locale: NS,
|
|
1506
|
+
label: () => t('pickSkill'),
|
|
1507
|
+
inject: () => ({ t }),
|
|
1508
|
+
}, function SkillButtonSlot(apiProps) {
|
|
1509
|
+
return h(ComposerButtonSlot, {
|
|
1510
|
+
__t: t, label: t('pickSkill'), title: t('pickSkillTitle'),
|
|
1511
|
+
source: 'skill', sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
|
|
1512
|
+
})
|
|
1513
|
+
}))
|
|
1514
|
+
} catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('input.left:' + (e && e.message)); throw e }
|
|
1515
|
+
}, 'skills-management: input left button')
|
|
1362
1516
|
},
|
|
1363
1517
|
}
|
|
1518
|
+
|
|
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 缺席时按钮隐藏(与旧行为一致)。 */
|
|
1525
|
+
function ComposerButtonSlot(props) {
|
|
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
|
|
1532
|
+
const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
|
|
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
|
|
1554
|
+
return h('button', {
|
|
1555
|
+
className: 'sk-chip',
|
|
1556
|
+
ref: btnRef,
|
|
1557
|
+
title: props.title || props.label,
|
|
1558
|
+
'aria-haspopup': 'dialog',
|
|
1559
|
+
'aria-expanded': picker !== null,
|
|
1560
|
+
onClick: open,
|
|
1561
|
+
}, props.label, popover)
|
|
1562
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weibaohui/skills-management",
|
|
3
|
-
"version": "0.
|
|
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
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
231
|
-
|
|
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) {
|
|
@@ -381,17 +405,35 @@ function authedUrl(url, token) {
|
|
|
381
405
|
return String(url).replace(/^(https?:\/\/)([^@/]+@)?/, `$1oauth2:${encodeURIComponent(token)}@`)
|
|
382
406
|
}
|
|
383
407
|
|
|
384
|
-
/** Clone (first time) or fetch+reset (update); remote branch is truth.
|
|
385
|
-
|
|
408
|
+
/** Clone (first time) or fetch+reset (update); remote branch is truth.
|
|
409
|
+
* `sparsePaths` (e.g. ['skills']) switches the checkout to sparse mode: fresh
|
|
410
|
+
* clones pass --filter=blob:none --sparse so only those subtrees download
|
|
411
|
+
* (the ntd-resource monorepo also carries experts/ + templates/, ~2x the
|
|
412
|
+
* skills payload); servers without filter support just warn and fall back to
|
|
413
|
+
* a full clone, which sparse-checkout still prunes. An existing full checkout
|
|
414
|
+
* is converted in place — the worktree prunes immediately, already-packed
|
|
415
|
+
* blobs stay (reachable from HEAD), so the big win is on fresh clones. */
|
|
416
|
+
async function gitSyncRepo(binary, url, branch, repoDir, token, sparsePaths) {
|
|
386
417
|
const remote = authedUrl(url, token)
|
|
418
|
+
const sparse = Array.isArray(sparsePaths) && sparsePaths.length > 0 ? sparsePaths : undefined
|
|
387
419
|
let repoExists = false
|
|
388
420
|
try { await fsP.access(join(repoDir, '.git')); repoExists = true } catch { repoExists = false }
|
|
389
421
|
if (!repoExists) {
|
|
390
422
|
await fsP.rm(repoDir, { recursive: true, force: true })
|
|
391
423
|
await fsP.mkdir(join(repoDir, '..'), { recursive: true })
|
|
392
|
-
|
|
424
|
+
if (sparse) {
|
|
425
|
+
await gitExec(binary, ['clone', '-b', branch, '--depth', '1', '--filter=blob:none', '--sparse', remote, repoDir])
|
|
426
|
+
await gitExec(binary, ['sparse-checkout', 'set', '--cone', ...sparse], repoDir)
|
|
427
|
+
} else {
|
|
428
|
+
await gitExec(binary, ['clone', '-b', branch, '--depth', '1', remote, repoDir])
|
|
429
|
+
}
|
|
393
430
|
return { isFirstClone: true, hasUpdates: true, before: undefined, after: await gitCurrentCommit(binary, repoDir) }
|
|
394
431
|
}
|
|
432
|
+
// Migrate a pre-sparse full checkout in place (idempotent no-op once sparse).
|
|
433
|
+
if (sparse) {
|
|
434
|
+
try { await gitExec(binary, ['sparse-checkout', 'set', '--cone', ...sparse], repoDir) }
|
|
435
|
+
catch (e) { console.warn(`skills-management: sparse-checkout conversion failed, continuing full: ${e && e.message}`) }
|
|
436
|
+
}
|
|
395
437
|
const before = await gitCurrentCommit(binary, repoDir)
|
|
396
438
|
await gitExec(binary, ['fetch', remote, branch], repoDir)
|
|
397
439
|
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir)
|
|
@@ -555,6 +597,11 @@ module.exports = {
|
|
|
555
597
|
}
|
|
556
598
|
const marketRoots = () => configMarketDirs !== undefined ? configMarketDirs : [join(effectiveRepoDir(), 'skills')]
|
|
557
599
|
const marketDirs = marketRoots // scan/install/locate call sites read through this
|
|
600
|
+
// 稀疏检出子树:ntd-resource 仓库同时携带 experts/templates,只检 skills 一份省一半以上
|
|
601
|
+
// 流量与磁盘。config.marketSparsePaths: null 关闭;自定义数组换目标子树。
|
|
602
|
+
const marketSparsePaths = () => config.marketSparsePaths === null
|
|
603
|
+
? undefined
|
|
604
|
+
: (Array.isArray(config.marketSparsePaths) && config.marketSparsePaths.length > 0 ? config.marketSparsePaths.map(String) : ['skills'])
|
|
558
605
|
const installedDir = resolve(expandTilde(config.installedDir !== undefined ? config.installedDir : process.env.DSH_HOME ? join(process.env.DSH_HOME, 'skills') : join(homedir(), '.dsh', 'skills')))
|
|
559
606
|
const providerName = config.providerName !== undefined ? config.providerName : 'ntd-skills'
|
|
560
607
|
// 市场库存(数几千条)默认不进模型目录 available_skills —— 只作为可浏览/可安装的货架。
|
|
@@ -699,9 +746,8 @@ module.exports = {
|
|
|
699
746
|
const row = findExecutorRow(key)
|
|
700
747
|
if (row === undefined) throw new Error(`unknown executor '${key}'`)
|
|
701
748
|
if (row.readOnly) throw new Error(`source '${key}' is read-only; cannot delete skills there`)
|
|
702
|
-
const target =
|
|
703
|
-
|
|
704
|
-
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})`)
|
|
705
751
|
await fsP.rm(target, { recursive: true })
|
|
706
752
|
if (key === 'dsh') invalidate()
|
|
707
753
|
return { removed: name, executor: key }
|
|
@@ -775,7 +821,7 @@ module.exports = {
|
|
|
775
821
|
if (!ok) throw new Error('git is not available on PATH')
|
|
776
822
|
const started = Date.now()
|
|
777
823
|
const repoDir = effectiveRepoDir()
|
|
778
|
-
const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token)
|
|
824
|
+
const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token, marketSparsePaths())
|
|
779
825
|
marketState.lastSyncAt = new Date().toISOString()
|
|
780
826
|
marketState.lastResult = { ...result, at: marketState.lastSyncAt, durationMs: Date.now() - started }
|
|
781
827
|
await saveMarketState()
|
|
@@ -902,6 +948,7 @@ module.exports = {
|
|
|
902
948
|
autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
|
|
903
949
|
hasToken: typeof eff.token === 'string' && eff.token !== '',
|
|
904
950
|
syncing: marketSyncRun !== null,
|
|
951
|
+
sparsePaths: marketSparsePaths() ?? null,
|
|
905
952
|
})
|
|
906
953
|
return
|
|
907
954
|
}
|