@zhangchi835/skill-manager 1.0.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.
@@ -0,0 +1,225 @@
1
+ return {
2
+ apply(ctx) {
3
+ const slots = ctx.get('slots')
4
+ if (slots === undefined) return
5
+
6
+ const CSS = [
7
+ '.skh-trigger-wrap{display:inline-flex;align-items:center}',
8
+ '.skh-trigger{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1;cursor:pointer}',
9
+ '.skh-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}',
10
+ '.skh-trigger-on{border-color:var(--dsw-alias-accent, #5b8cff);color:var(--dsw-alias-label-primary)}',
11
+ '.skh-trigger-icon{font-size:13px;line-height:1}',
12
+ '.skh-trigger-text{font-weight:500}',
13
+ '.skh-trigger-count{min-width:15px;height:15px;padding:0 4px;border-radius:8px;background:var(--dsw-alias-accent, #5b8cff);color:#fff;font-size:10px;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box}',
14
+ '.skh-pop-backdrop{position:fixed;inset:0;z-index:90;background:transparent}',
15
+ '.skh-pop-card{position:absolute;bottom:calc(100% + 6px);left:0;z-index:100;min-width:280px;max-width:min(380px,100%);max-height:min(360px,65vh);background:var(--dsw-specific-menu, var(--dsw-alias-bg-base));border:1px solid var(--dsw-alias-border-inverted, var(--dsw-alias-border-l1));border-radius:12px;box-shadow:var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.2));display:flex;flex-direction:column;overflow:hidden;padding:8px;box-sizing:border-box;color:var(--dsw-alias-label-primary);text-align:left}',
16
+ '.skh-pop-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);padding:2px 6px 6px}',
17
+ '.skh-pop-list{overflow-y:auto;min-height:0;display:flex;flex-direction:column;gap:2px}',
18
+ '.skh-pop-row{display:flex;align-items:flex-start;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer}',
19
+ '.skh-pop-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
20
+ '.skh-pop-check{margin:3px 0 0;flex:none;accent-color:var(--dsw-alias-accent, #5b8cff)}',
21
+ '.skh-pop-meta{min-width:0;flex:1}',
22
+ '.skh-pop-name{font-size:13px;font-weight:600;word-break:break-all}',
23
+ '.skh-pop-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}',
24
+ '.skh-pop-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:8px}',
25
+ '.skh-pop-foot{display:flex;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2, transparent);margin-top:6px;padding:8px 6px 2px}',
26
+ '.skh-pop-folder{flex:1;min-width:0;font-size:11px;color:var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
27
+ '.skh-pop-done{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;padding:3px 10px;font-size:12px;cursor:pointer}',
28
+ '.skh-pop-done:hover{background:var(--dsw-alias-interactive-bg-hover)}',
29
+ '.skh-admin{display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-size:13px}',
30
+ '.skh-title{font-size:14px;font-weight:600}',
31
+ '.skh-sub{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
32
+ '.skh-field{display:flex;gap:8px}',
33
+ '.skh-input{flex:1;min-width:0;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 8px;font-size:12px;outline:none}',
34
+ '.skh-btn{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-primary);border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;white-space:nowrap}',
35
+ '.skh-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
36
+ '.skh-status{font-size:12px;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}',
37
+ '.skh-status-err{color:var(--dsw-alias-state-error-primary)}',
38
+ '.skh-row{display:flex;flex-direction:column;gap:2px;border:1px solid var(--dsw-alias-border-l2, var(--dsw-alias-border-l1));border-radius:8px;padding:8px}',
39
+ '.skh-row-top{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}',
40
+ '.skh-row-name{font-weight:600;font-size:13px;word-break:break-all}',
41
+ '.skh-badge{flex:none;font-size:11px;border-radius:6px;padding:1px 6px;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}',
42
+ '.skh-badge-on{border-color:var(--dsw-alias-accent, #5b8cff);color:var(--dsw-alias-accent, #5b8cff)}',
43
+ '.skh-row-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}',
44
+ '.skh-row-rel{font-size:11px;color:var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary));overflow-wrap:anywhere}',
45
+ '.skh-note{font-size:12px;color:var(--dsw-alias-label-tertiary);border:1px dashed var(--dsw-alias-border-l1);border-radius:8px;padding:8px}',
46
+ '.skh-panel{display:flex;flex-direction:column;gap:8px;color:var(--dsw-alias-label-primary);font-size:12px}',
47
+ ].join('\n')
48
+
49
+ const store = {
50
+ view: null,
51
+ loadError: null,
52
+ open: false,
53
+ listeners: [],
54
+ notify: function () { for (let i = 0; i < this.listeners.length; i++) { try { this.listeners[i]() } catch (_) {} } },
55
+ set: function (patch) { for (const key in patch) this[key] = patch[key]; this.notify() },
56
+ subscribe: function (listener) { this.listeners.push(listener); return () => { this.listeners = this.listeners.filter((l) => l !== listener) } },
57
+ }
58
+ function refresh() {
59
+ host.call('state').then(function (view) { store.set({ view: view, loadError: null }) }, function (error) { store.set({ loadError: String(error) }) })
60
+ }
61
+ function applySelection(names) {
62
+ host.call('setSelected', { names: names }).then(refresh, refresh)
63
+ }
64
+ function useStore() {
65
+ const [tick, setTick] = React.useState(0)
66
+ React.useEffect(function () { return store.subscribe(function () { setTick(function (t) { return t + 1 }) }) }, [])
67
+ }
68
+
69
+ function SkillsTrigger() {
70
+ useStore()
71
+ const view = store.view
72
+ const count = view === null ? 0 : view.selected.length
73
+ const on = count > 0
74
+ return React.createElement('div', { className: 'skh-trigger-wrap' },
75
+ React.createElement('button', {
76
+ type: 'button',
77
+ className: 'skh-trigger' + (on ? ' skh-trigger-on' : ''),
78
+ title: on ? '已启用技能: ' + view.selected.join(', ') : '选择会话技能(勾选即启用)',
79
+ onClick: function () { store.set({ open: !store.open }) },
80
+ },
81
+ React.createElement('span', { className: 'skh-trigger-icon', 'aria-hidden': 'true' }, '\uD83E\uDDE9'),
82
+ React.createElement('span', { className: 'skh-trigger-text' }, '技能'),
83
+ on ? React.createElement('span', { className: 'skh-trigger-count' }, String(count)) : null))
84
+ }
85
+
86
+ function SkillsPopup() {
87
+ useStore()
88
+ if (!store.open) return null
89
+ const view = store.view
90
+ const skills = view === null ? [] : view.skills
91
+ const selectedNames = view === null ? [] : view.selected
92
+ const selectedSet = {}
93
+ for (let i = 0; i < selectedNames.length; i++) selectedSet[selectedNames[i]] = true
94
+ const toggle = function (name) {
95
+ const next = []
96
+ for (let i = 0; i < selectedNames.length; i++) if (selectedNames[i] !== name) next.push(selectedNames[i])
97
+ if (selectedSet[name] !== true) next.push(name)
98
+ applySelection(next)
99
+ }
100
+ const emptyText = view === null || view.folder === ''
101
+ ? '尚未配置技能文件夹:请到 设置 → 技能管理 中设置'
102
+ : (view.scanError !== '' ? view.scanError : '技能文件夹中没有 .md 文件')
103
+ return React.createElement('div', null,
104
+ React.createElement('div', { className: 'skh-pop-backdrop', onClick: function () { store.set({ open: false }) } }),
105
+ React.createElement('div', { className: 'skh-pop-card', role: 'dialog', 'aria-label': '会话技能选择' },
106
+ React.createElement('div', { className: 'skh-pop-title' }, '会话技能(勾选即启用,未勾选不启用)'),
107
+ skills.length === 0 ? React.createElement('div', { className: 'skh-pop-empty' }, emptyText) : null,
108
+ React.createElement('div', { className: 'skh-pop-list' },
109
+ skills.map(function (s) {
110
+ return React.createElement('label', { key: s.name, className: 'skh-pop-row' },
111
+ React.createElement('input', { type: 'checkbox', className: 'skh-pop-check', checked: selectedSet[s.name] === true, onChange: function () { toggle(s.name) } }),
112
+ React.createElement('div', { className: 'skh-pop-meta' },
113
+ React.createElement('div', { className: 'skh-pop-name' }, s.name),
114
+ React.createElement('div', { className: 'skh-pop-desc' }, s.description === null || s.description === '' ? s.rel : s.description)))
115
+ })),
116
+ React.createElement('div', { className: 'skh-pop-foot' },
117
+ React.createElement('span', { className: 'skh-pop-folder', title: view === null ? '' : view.folder }, view === null ? '' : view.folder),
118
+ React.createElement('button', { type: 'button', className: 'skh-pop-done', onClick: function () { store.set({ open: false }) } }, '完成'))))
119
+ }
120
+
121
+ function SkillsAdmin() {
122
+ useStore()
123
+ const view = store.view
124
+ const [folderDraft, setFolderDraft] = React.useState(null)
125
+ const [draftTouched, setDraftTouched] = React.useState(false)
126
+ if (folderDraft === null && view !== null && !draftTouched) setFolderDraft(view.folder)
127
+ const skills = view === null ? [] : view.skills
128
+ const others = view === null ? [] : view.otherSkills
129
+ const selectedSet = {}
130
+ if (view !== null) for (let i = 0; i < view.selected.length; i++) selectedSet[view.selected[i]] = true
131
+ let statusText = ''
132
+ let statusErr = false
133
+ if (store.loadError !== null) { statusText = '状态读取失败: ' + store.loadError; statusErr = true }
134
+ else if (view === null) statusText = '正在读取状态…'
135
+ else if (view.scanning) statusText = '正在扫描…'
136
+ else if (!view.scanned) statusText = '尚未扫描'
137
+ else if (view.scanError !== '') { statusText = view.scanError; statusErr = true }
138
+ else statusText = '已扫描 ' + skills.length + ' 个技能' + (view.scanTime === null || view.scanTime === '' ? '' : ' · ' + view.scanTime)
139
+ const applyFolder = function () {
140
+ host.call('setFolder', { path: folderDraft === null ? '' : folderDraft }).then(refresh, refresh)
141
+ }
142
+ const reload = function () { host.call('reload').then(refresh, refresh) }
143
+ return React.createElement('div', { className: 'skh-admin' },
144
+ React.createElement('div', { className: 'skh-title' }, '技能管理'),
145
+ React.createElement('div', { className: 'skh-sub' }, '技能文件夹'),
146
+ React.createElement('div', { className: 'skh-field' },
147
+ React.createElement('input', {
148
+ className: 'skh-input',
149
+ value: folderDraft === null ? '' : folderDraft,
150
+ placeholder: '技能文件夹绝对路径(递归扫描所有 .md)',
151
+ onChange: function (event) { setDraftTouched(true); setFolderDraft(event.target.value) },
152
+ }),
153
+ React.createElement('button', { type: 'button', className: 'skh-btn', onClick: applyFolder }, '保存并加载'),
154
+ React.createElement('button', { type: 'button', className: 'skh-btn', onClick: reload }, '重新扫描')),
155
+ React.createElement('div', { className: statusErr ? 'skh-status skh-status-err' : 'skh-status' }, statusText),
156
+ React.createElement('div', { className: 'skh-sub' }, '本文件夹技能(' + skills.length + ')'),
157
+ skills.map(function (s) {
158
+ return React.createElement('div', { key: s.name, className: 'skh-row' },
159
+ React.createElement('div', { className: 'skh-row-top' },
160
+ React.createElement('span', { className: 'skh-row-name' }, s.name),
161
+ React.createElement('span', { className: selectedSet[s.name] === true ? 'skh-badge skh-badge-on' : 'skh-badge' }, selectedSet[s.name] === true ? '已选择' : '未选择')),
162
+ React.createElement('div', { className: 'skh-row-desc' }, s.description === null || s.description === '' ? '(无描述)' : s.description),
163
+ React.createElement('div', { className: 'skh-row-rel' }, s.rel))
164
+ }),
165
+ React.createElement('div', { className: 'skh-sub' }, '会话中可见的其他技能(' + others.length + ')'),
166
+ others.map(function (s) {
167
+ return React.createElement('div', { key: s.provider + '/' + s.name, className: 'skh-row' },
168
+ React.createElement('div', { className: 'skh-row-top' },
169
+ React.createElement('span', { className: 'skh-row-name' }, s.name),
170
+ React.createElement('span', { className: 'skh-badge' }, s.provider)),
171
+ React.createElement('div', { className: 'skh-row-desc' }, s.description === null || s.description === '' ? '(无描述)' : s.description))
172
+ }),
173
+ React.createElement('div', { className: 'skh-note' }, '启用/停用请在输入框工具行「模式」选择器旁的「技能」选择器中勾选;本页仅管理与查看,不提供启用/停用开关。'))
174
+ }
175
+
176
+ function SkillsRunPanel() {
177
+ useStore()
178
+ const view = store.view
179
+ const [folderDraft, setFolderDraft] = React.useState(null)
180
+ const [draftTouched, setDraftTouched] = React.useState(false)
181
+ if (folderDraft === null && view !== null && !draftTouched) setFolderDraft(view.folder)
182
+ const skills = view === null ? [] : view.skills
183
+ const selected = view === null ? [] : view.selected
184
+ let statusText = ''
185
+ let statusErr = false
186
+ if (store.loadError !== null) { statusText = 'RPC 失败: ' + store.loadError; statusErr = true }
187
+ else if (view === null) statusText = '等待 Host 状态…'
188
+ else if (view.scanning) statusText = '正在扫描…'
189
+ else if (view.scanError !== '') { statusText = view.scanError; statusErr = true }
190
+ else statusText = '已扫描 ' + skills.length + ' 个技能,已勾选启用 ' + selected.length + ' 个'
191
+ const applyFolder = function () {
192
+ host.call('setFolder', { path: folderDraft === null ? '' : folderDraft }).then(refresh, refresh)
193
+ }
194
+ return React.createElement('div', { className: 'skh-panel' },
195
+ React.createElement('div', { className: 'skh-title' }, '🧩 技能加载与管理插件 · 已安装'),
196
+ React.createElement('div', { className: 'skh-field' },
197
+ React.createElement('input', {
198
+ className: 'skh-input',
199
+ value: folderDraft === null ? '' : folderDraft,
200
+ placeholder: '技能文件夹路径',
201
+ onChange: function (event) { setDraftTouched(true); setFolderDraft(event.target.value) },
202
+ }),
203
+ React.createElement('button', { type: 'button', className: 'skh-btn', onClick: applyFolder }, '加载'),
204
+ React.createElement('button', { type: 'button', className: 'skh-btn', onClick: function () { host.call('reload').then(refresh, refresh) } }, '重新扫描')),
205
+ React.createElement('div', { className: statusErr ? 'skh-status skh-status-err' : 'skh-status' }, statusText),
206
+ selected.length > 0 ? React.createElement('div', { className: 'skh-status' }, '已启用: ' + selected.join(', ')) : null,
207
+ React.createElement('div', { className: 'skh-note' }, '使用方法:输入框工具行的「模式」选择器旁点「技能」,勾选即启用、取消勾选即停用;管理页在 设置 → 技能管理。'))
208
+ }
209
+
210
+ ctx.effect(function () { return styles.insert(CSS) }, 'skills-hub css')
211
+ slots.inject('conversation.input.left', function () {
212
+ return slots.register({ name: 'conversation.input.left', id: 'skills-hub-trigger', order: 0 }, SkillsTrigger)
213
+ })
214
+ slots.inject('conversation.input.overlay', function () {
215
+ return slots.register({ name: 'conversation.input.overlay', id: 'skills-hub-popup', order: 2 }, SkillsPopup)
216
+ })
217
+ slots.inject('settings.section', function () {
218
+ return slots.register({ name: 'settings.section', id: 'skills-hub', order: 25, label: '技能管理' }, SkillsAdmin)
219
+ })
220
+ slots.inject('tool.view.cordis', function () {
221
+ return slots.register({ name: 'tool.view.cordis', key: 'self' }, SkillsRunPanel)
222
+ })
223
+ refresh()
224
+ },
225
+ }
@@ -0,0 +1,321 @@
1
+ return {
2
+ apply(ctx) {
3
+ const skills = ctx.get('skills')
4
+ if (skills === undefined) return
5
+ const fsService = ctx.get('fs')
6
+ const sandboxPolicy = ctx.get('sandboxPolicy')
7
+
8
+ let workspaceRoot = ''
9
+ if (sandboxPolicy !== undefined && typeof sandboxPolicy.workspaceRoot === 'string') workspaceRoot = sandboxPolicy.workspaceRoot
10
+
11
+ const state = {
12
+ folder: workspaceRoot === '' ? '' : workspaceRoot.replace(/[\\/]+$/, '') + '/skills',
13
+ folderExists: false,
14
+ scanned: false,
15
+ scanning: false,
16
+ scanTime: null,
17
+ scanError: '',
18
+ skills: [],
19
+ selected: [],
20
+ otherSkills: [],
21
+ }
22
+ let generation = 0
23
+ let scanPromise = null
24
+ let invalidate = function () {}
25
+
26
+ function msg(error) {
27
+ return error !== null && typeof error === 'object' && typeof error.message === 'string' ? error.message : String(error)
28
+ }
29
+ function kebab(value) {
30
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').replace(/-+$/, '')
31
+ }
32
+ function hashText(text) {
33
+ let h = 5381
34
+ for (let i = 0; i < text.length; i++) h = (((h << 5) + h) + text.charCodeAt(i)) >>> 0
35
+ return h.toString(36)
36
+ }
37
+ function firstLine(text) {
38
+ const match = /^\s*([^\n\r]*)/.exec(text === undefined || text === null ? '' : String(text))
39
+ return match === null ? '' : match[1].trim()
40
+ }
41
+
42
+ function parseFrontmatterFields(raw) {
43
+ const data = Object.create(null)
44
+ const lines = raw.split('\n')
45
+ for (let i = 0; i < lines.length; i++) {
46
+ const line = lines[i].replace(/\r$/, '')
47
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line)
48
+ if (match === null) continue
49
+ const key = match[1]
50
+ let value = match[2].trim()
51
+ const first = value.charAt(0)
52
+ const last = value.charAt(value.length - 1)
53
+ if ((first === '"' && last === '"' && value.length >= 2) || (first === "'" && last === "'" && value.length >= 2)) value = value.slice(1, -1)
54
+ else if (value === 'true' || value === 'false') value = value === 'true'
55
+ else if (/^-?\d+$/.test(value)) value = Number(value)
56
+ data[key] = value
57
+ }
58
+ return data
59
+ }
60
+ function parseFrontmatter(text) {
61
+ if (typeof text !== 'string' || text.length === 0) return null
62
+ const firstEnd = text.indexOf('\n')
63
+ if (firstEnd < 0) return null
64
+ if (text.slice(0, firstEnd).replace(/\r$/, '') !== '---') return null
65
+ let lineStart = firstEnd + 1
66
+ while (lineStart <= text.length) {
67
+ const nextNewline = text.indexOf('\n', lineStart)
68
+ const lineEnd = nextNewline < 0 ? text.length : nextNewline
69
+ if (text.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
70
+ const bodyStart = nextNewline < 0 ? text.length : nextNewline + 1
71
+ return { data: parseFrontmatterFields(text.slice(firstEnd + 1, lineStart)), body: text.slice(bodyStart) }
72
+ }
73
+ if (nextNewline < 0) return null
74
+ lineStart = nextNewline + 1
75
+ }
76
+ return null
77
+ }
78
+
79
+ function parseSkill(text, rel) {
80
+ const fm = parseFrontmatter(text)
81
+ const fileName = rel.replace(/^.*[\\/]/, '')
82
+ const stem = fileName.replace(/\.md$/i, '')
83
+ let name = null
84
+ let description = null
85
+ let whenToUse
86
+ let metadata
87
+ if (fm !== null) {
88
+ if (typeof fm.data.name === 'string' && fm.data.name.trim() !== '') name = fm.data.name.trim()
89
+ if (typeof fm.data.description === 'string' && fm.data.description.trim() !== '') description = fm.data.description.trim()
90
+ if (typeof fm.data['whenToUse'] === 'string' && fm.data['whenToUse'].trim() !== '') whenToUse = fm.data['whenToUse'].trim()
91
+ if (fm.data.metadata !== null && typeof fm.data.metadata === 'object' && !Array.isArray(fm.data.metadata)) metadata = fm.data.metadata
92
+ }
93
+ if (name === null) name = stem
94
+ let finalName = kebab(name)
95
+ if (finalName === '') finalName = 'skill-' + hashText(rel)
96
+ if (finalName.length > 64) finalName = finalName.slice(0, 64).replace(/-+$/, '')
97
+ const body = fm === null ? text : fm.body
98
+ if (description === null) {
99
+ description = firstLine(body)
100
+ if (description === '') description = 'Skill loaded from ' + rel
101
+ }
102
+ return { name: finalName, description: description, whenToUse: whenToUse, metadata: metadata, content: String(body).trim() }
103
+ }
104
+
105
+ async function walk(dirTarget, rel, found, errors, depth, budget) {
106
+ if (depth > 24 || budget.count >= 4000) return
107
+ let entries
108
+ try { entries = await fsService.listDir(dirTarget, undefined) } catch (error) { errors.push('listDir failed ' + rel + ': ' + msg(error)); return }
109
+ for (let i = 0; i < entries.length && budget.count < 4000; i++) {
110
+ const entry = entries[i]
111
+ budget.count++
112
+ const childRel = rel === '' ? entry.name : rel + '/' + entry.name
113
+ if (entry.type === 'directory') {
114
+ if (entry.target === undefined || entry.target === null) continue
115
+ await walk(entry.target, childRel, found, errors, depth + 1, budget)
116
+ } else if (entry.type === 'file' && /\.md$/i.test(entry.name)) {
117
+ if (entry.target === undefined || entry.target === null) continue
118
+ let text
119
+ try { text = await fsService.readText(entry.target, undefined) } catch (error) { errors.push('read failed ' + childRel + ': ' + msg(error)); continue }
120
+ const parsed = parseSkill(text, childRel)
121
+ const displayPath = typeof entry.target.displayPath === 'string' ? entry.target.displayPath : state.folder + '/' + childRel
122
+ found.push({ name: parsed.name, description: parsed.description, whenToUse: parsed.whenToUse, rel: childRel, path: displayPath, metadata: parsed.metadata })
123
+ }
124
+ }
125
+ }
126
+
127
+ async function collectOtherSkills() {
128
+ try {
129
+ const list = await skills.list({ cwd: workspaceRoot === '' ? undefined : workspaceRoot })
130
+ const out = []
131
+ for (let i = 0; i < list.length; i++) {
132
+ const s = list[i]
133
+ if (s === null || typeof s !== 'object' || s.provider === 'skills-hub') continue
134
+ out.push({
135
+ name: typeof s.name === 'string' ? s.name : '',
136
+ description: typeof s.description === 'string' ? s.description : '',
137
+ provider: typeof s.provider === 'string' ? s.provider : '',
138
+ source: typeof s.source === 'string' ? s.source : '',
139
+ })
140
+ }
141
+ return out
142
+ } catch (error) {
143
+ return []
144
+ }
145
+ }
146
+
147
+ async function scanNow() {
148
+ const gen = ++generation
149
+ state.scanning = true
150
+ state.scanError = ''
151
+ const errors = []
152
+ const found = []
153
+ try {
154
+ if (state.folder === '') {
155
+ // 未配置文件夹:静默等待配置
156
+ } else if (fsService === undefined) {
157
+ errors.push('filesystem service unavailable')
158
+ } else {
159
+ let target
160
+ try { target = await fsService.resolve(state.folder) } catch (error) { errors.push('resolve failed: ' + msg(error)) }
161
+ if (target !== undefined) {
162
+ let info
163
+ try { info = await fsService.stat(target) } catch (error) { errors.push('stat failed: ' + msg(error)) }
164
+ if (info === undefined) { state.folderExists = false; errors.push('folder not found: ' + state.folder) }
165
+ else if (info.type !== 'directory') { state.folderExists = false; errors.push('not a directory: ' + state.folder) }
166
+ else { state.folderExists = true; await walk(target, '', found, errors, 0, { count: 0 }) }
167
+ }
168
+ }
169
+ } catch (error) { errors.push(msg(error)) }
170
+ if (gen !== generation) return
171
+ const seen = Object.create(null)
172
+ const out = []
173
+ for (let i = 0; i < found.length; i++) {
174
+ const s = found[i]
175
+ if (seen[s.name] === true) continue
176
+ seen[s.name] = true
177
+ out.push(s)
178
+ }
179
+ out.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 })
180
+ const keep = []
181
+ for (let i = 0; i < state.selected.length; i++) if (seen[state.selected[i]] === true) keep.push(state.selected[i])
182
+ state.selected = keep
183
+ state.skills = out
184
+ state.scanError = errors.join('; ')
185
+ state.scanned = true
186
+ state.scanning = false
187
+ state.scanTime = typeof Date === 'function' ? new Date().toISOString() : null
188
+ state.otherSkills = await collectOtherSkills()
189
+ try { invalidate() } catch (_) {}
190
+ console.log('[skills-hub] scan complete: ' + out.length + ' skills, errors: ' + (errors.length === 0 ? 'none' : errors.join('; ')))
191
+ }
192
+
193
+ function scheduleScan() {
194
+ if (scanPromise !== null) return scanPromise
195
+ const promise = scanNow().catch(function (error) { console.error('[skills-hub] scan failed:', msg(error)) }).finally(function () { scanPromise = null })
196
+ scanPromise = promise
197
+ return promise
198
+ }
199
+
200
+ ctx.effect(function () {
201
+ return skills.registerProvider(function (control) {
202
+ invalidate = function () { try { control.invalidate() } catch (_) {} }
203
+ return {
204
+ name: 'skills-hub',
205
+ async list(options) {
206
+ const signal = options === undefined ? undefined : options.signal
207
+ const selectedSet = Object.create(null)
208
+ for (let i = 0; i < state.selected.length; i++) selectedSet[state.selected[i]] = true
209
+ const out = []
210
+ for (let i = 0; i < state.skills.length; i++) {
211
+ if (signal !== undefined && signal.aborted) break
212
+ const s = state.skills[i]
213
+ if (selectedSet[s.name] !== true) continue
214
+ const candidate = {
215
+ name: s.name,
216
+ description: s.description,
217
+ invocation: { modelInvocable: true, userInvocable: true },
218
+ source: 'custom',
219
+ provider: 'skills-hub',
220
+ rank: 350,
221
+ locator: { path: s.path, rel: s.rel },
222
+ resourceBase: { kind: 'directory', path: state.folder },
223
+ path: s.path,
224
+ }
225
+ if (s.whenToUse !== undefined) candidate.whenToUse = s.whenToUse
226
+ out.push(candidate)
227
+ }
228
+ return out
229
+ },
230
+ async get(candidate, options) {
231
+ const signal = options === undefined ? undefined : options.signal
232
+ const loc = candidate === null || candidate === undefined ? null : candidate.locator
233
+ const path = loc === null || loc === undefined ? undefined : loc.path
234
+ if (typeof path !== 'string' || path === '' || fsService === undefined) return undefined
235
+ if (signal !== undefined && signal.aborted) return undefined
236
+ let target
237
+ try { target = await fsService.resolve(path, { signal: signal }) } catch (_) { return undefined }
238
+ if (signal !== undefined && signal.aborted) return undefined
239
+ let text
240
+ try { text = await fsService.readText(target, signal) } catch (_) { return undefined }
241
+ if (signal !== undefined && signal.aborted) return undefined
242
+ const rel = loc === null || loc === undefined ? '' : loc.rel
243
+ const parsed = parseSkill(text, typeof rel === 'string' ? rel : '')
244
+ const definition = {
245
+ name: candidate.name,
246
+ description: parsed.description,
247
+ invocation: { modelInvocable: true, userInvocable: true },
248
+ source: 'custom',
249
+ provider: 'skills-hub',
250
+ resourceBase: { kind: 'directory', path: state.folder },
251
+ path: path,
252
+ content: parsed.content,
253
+ }
254
+ if (parsed.whenToUse !== undefined) definition.whenToUse = parsed.whenToUse
255
+ if (parsed.metadata !== undefined) definition.metadata = parsed.metadata
256
+ return definition
257
+ },
258
+ }
259
+ })
260
+ }, 'skills-hub provider')
261
+
262
+ function selectedSetOf() {
263
+ const set = Object.create(null)
264
+ for (let i = 0; i < state.selected.length; i++) set[state.selected[i]] = true
265
+ return set
266
+ }
267
+ async function buildView() {
268
+ const set = selectedSetOf()
269
+ const skillRows = []
270
+ for (let i = 0; i < state.skills.length; i++) {
271
+ const s = state.skills[i]
272
+ skillRows.push({
273
+ name: s.name,
274
+ description: s.description,
275
+ whenToUse: s.whenToUse === undefined ? null : s.whenToUse,
276
+ rel: s.rel,
277
+ path: s.path,
278
+ selected: set[s.name] === true,
279
+ })
280
+ }
281
+ return {
282
+ folder: state.folder,
283
+ folderExists: state.folderExists,
284
+ scanned: state.scanned,
285
+ scanning: state.scanning,
286
+ scanTime: state.scanTime,
287
+ scanError: state.scanError,
288
+ skills: skillRows,
289
+ selected: state.selected.slice(),
290
+ otherSkills: state.otherSkills.slice(),
291
+ }
292
+ }
293
+
294
+ ctx.effect(function () { return harness.handle('state', function () { return buildView() }) }, 'skills-hub rpc state')
295
+ ctx.effect(function () {
296
+ return harness.handle('setFolder', function (args) {
297
+ const path = args !== null && typeof args === 'object' && typeof args.path === 'string' ? args.path : ''
298
+ const trimmed = path.trim()
299
+ if (trimmed !== '') state.folder = trimmed
300
+ return scheduleScan().then(buildView)
301
+ })
302
+ }, 'skills-hub rpc setFolder')
303
+ ctx.effect(function () {
304
+ return harness.handle('setSelected', function (args) {
305
+ const names = args !== null && typeof args === 'object' && Array.isArray(args.names) ? args.names.filter(function (n) { return typeof n === 'string' }) : []
306
+ const known = Object.create(null)
307
+ for (let i = 0; i < state.skills.length; i++) known[state.skills[i].name] = true
308
+ const next = []
309
+ for (let i = 0; i < names.length; i++) if (known[names[i]] === true && next.indexOf(names[i]) < 0) next.push(names[i])
310
+ state.selected = next
311
+ try { invalidate() } catch (_) {}
312
+ return buildView()
313
+ })
314
+ }, 'skills-hub rpc setSelected')
315
+ ctx.effect(function () {
316
+ return harness.handle('reload', function () { return scheduleScan().then(buildView) })
317
+ }, 'skills-hub rpc reload')
318
+
319
+ scheduleScan()
320
+ },
321
+ }