@zhangchi835/skill-manager 1.0.0 → 1.0.3

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.
@@ -1,321 +0,0 @@
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
- }
@@ -1,190 +0,0 @@
1
- // skills-hub Host 逻辑模拟验证(使用真实 workspace/skills 文件夹 + mock fs/skills/harness)
2
- const fs = require('node:fs')
3
- const path = require('node:path')
4
-
5
- const workspaceRoot = path.resolve(__dirname, '..')
6
- const skillsFolder = path.join(workspaceRoot, 'skills')
7
-
8
- function makeTarget(p) {
9
- return { displayPath: p }
10
- }
11
-
12
- const fsService = {
13
- async resolve(p) {
14
- if (typeof p !== 'string') throw new Error('bad path')
15
- return makeTarget(path.resolve(p))
16
- },
17
- async stat(target, signal) {
18
- try {
19
- const s = fs.statSync(target.displayPath)
20
- return { type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other' }
21
- } catch (error) {
22
- return undefined
23
- }
24
- },
25
- async listDir(target, signal) {
26
- const names = fs.readdirSync(target.displayPath)
27
- const out = []
28
- for (const name of names) {
29
- const full = path.join(target.displayPath, name)
30
- let type = 'other'
31
- try {
32
- const s = fs.statSync(full)
33
- type = s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other'
34
- } catch (_) {}
35
- out.push({ name, type, target: makeTarget(full) })
36
- }
37
- return out
38
- },
39
- async readText(target, signal) {
40
- return fs.readFileSync(target.displayPath, 'utf8')
41
- },
42
- }
43
-
44
- let invalidateCount = 0
45
- let providerRef = null
46
- const skillsMock = {
47
- registerProvider(factory) {
48
- providerRef = factory({
49
- signal: { addEventListener() {} },
50
- invalidate() { invalidateCount++ },
51
- })
52
- return () => {}
53
- },
54
- async list(options) {
55
- // 模拟“其他提供者”返回两条只读技能,验证 otherSkills 收集
56
- return [
57
- { name: 'shipped-a', description: 'shipped skill a', provider: 'filesystem', source: 'bundled' },
58
- { name: 'shipped-b', description: 'shipped skill b', provider: 'filesystem', source: 'bundled' },
59
- ]
60
- },
61
- }
62
-
63
- const handlers = {}
64
- const harnessMock = {
65
- handle(method, handler) {
66
- handlers[method] = handler
67
- return () => {}
68
- },
69
- }
70
-
71
- const ctx = {
72
- get(name) {
73
- if (name === 'skills') return skillsMock
74
- if (name === 'fs') return fsService
75
- if (name === 'sandboxPolicy') return { workspaceRoot }
76
- return undefined
77
- },
78
- effect(cb, label) {
79
- cb()
80
- return () => {}
81
- },
82
- on() {
83
- return () => {}
84
- },
85
- }
86
-
87
- function assert(condition, label) {
88
- if (!condition) {
89
- console.error('FAIL:', label)
90
- process.exitCode = 1
91
- } else {
92
- console.log('PASS:', label)
93
- }
94
- }
95
-
96
- async function main() {
97
- // 1) 语法编译检查(host + client 代码体)
98
- const hostBody = fs.readFileSync(path.join(__dirname, 'host-body.js'), 'utf8')
99
- const clientBody = fs.readFileSync(path.join(__dirname, 'client-body.js'), 'utf8')
100
- try {
101
- new Function(hostBody)
102
- console.log('PASS: host body compiles')
103
- } catch (error) {
104
- console.error('FAIL: host body compile:', error.message)
105
- process.exitCode = 1
106
- }
107
- try {
108
- new Function(clientBody)
109
- console.log('PASS: client body compiles')
110
- } catch (error) {
111
- console.error('FAIL: client body compile:', error.message)
112
- process.exitCode = 1
113
- }
114
-
115
- const plugin = new Function('harness', hostBody)(harnessMock)
116
- plugin.apply(ctx)
117
-
118
- // 等待首次扫描(异步)
119
- for (let i = 0; i < 100; i++) {
120
- const view = await handlers['state']()
121
- if (!view.scanning) break
122
- await new Promise((r) => setTimeout(r, 20))
123
- }
124
- const view = await handlers['state']()
125
- console.log('STATE scanError:', JSON.stringify(view.scanError))
126
- console.log('STATE skills:', JSON.stringify(view.skills.map((s) => ({ name: s.name, rel: s.rel }))))
127
- console.log('STATE otherSkills:', JSON.stringify(view.otherSkills))
128
-
129
- assert(view.scanned === true, 'scan completed')
130
- assert(view.scanError === '', 'no scan errors, got: ' + view.scanError)
131
-
132
- // 2) 无命名规则验证:中文文件名、无 frontmatter 也能加载
133
- const chinese = view.skills.find((s) => s.rel.includes('中文'))
134
- assert(chinese !== undefined, 'Chinese filename with no frontmatter loaded, name=' + (chinese && chinese.name))
135
- if (chinese !== undefined) {
136
- assert(/^skill-[0-9a-z]+$/.test(chinese.name), 'hash fallback name is kebab-case: ' + chinese.name)
137
- }
138
- const guide = view.skills.find((s) => s.name === 'cordis-skills-manager-plugin')
139
- assert(guide !== undefined, 'distilled skill loaded via frontmatter name')
140
-
141
- // 3) 未勾选时 provider 不暴露任何技能(不是默认全启用)
142
- const listNone = await providerRef.list({})
143
- assert(listNone.length === 0, 'provider lists nothing when nothing selected')
144
-
145
- // 4) 只勾选一个 -> 只暴露那一个(选择即启用,精确匹配)
146
- const names = view.skills.map((s) => s.name)
147
- await handlers['setSelected']({ names: [names[0]] })
148
- const listOne = await providerRef.list({})
149
- assert(listOne.length === 1 && listOne[0].name === names[0], 'only the selected skill is exposed')
150
- assert(listOne[0].invocation.modelInvocable === true && listOne[0].invocation.userInvocable === true, 'invocation policy permits both')
151
-
152
- // 5) get() 返回完整定义(content 非空、resourceBase 正确)
153
- const definition = await providerRef.get(listOne[0], {})
154
- assert(definition !== undefined && typeof definition.content === 'string' && definition.content.length > 0, 'get() returns full definition')
155
- const norm = (p) => String(p).replace(/\\/g, '/')
156
- assert(definition.resourceBase.kind === 'directory' && norm(definition.resourceBase.path) === norm(skillsFolder), 'resourceBase directory = skills folder (separators normalized)')
157
-
158
- // 6) 勾选未知名称会被裁剪
159
- const afterBad = await handlers['setSelected']({ names: [names[0], 'not-exists', ''] })
160
- assert(afterBad.selected.length === 1, 'unknown names pruned')
161
-
162
- // 7) 全选后全部暴露
163
- await handlers['setSelected']({ names })
164
- const listAll = await providerRef.list({})
165
- assert(listAll.length === names.length, 'select all exposes all')
166
-
167
- // 8) 清空选择 -> 全部停用(精确匹配,不是全启用也不是全禁用)
168
- await handlers['setSelected']({ names: [] })
169
- const listEmpty = await providerRef.list({})
170
- assert(listEmpty.length === 0, 'clearing selection disables all managed skills')
171
-
172
- // 9) setFolder 到不存在目录 -> 状态报错不崩溃
173
- const badFolder = await handlers['setFolder']({ path: path.join(workspaceRoot, 'no-such-folder') })
174
- assert(badFolder.scanError !== '' && badFolder.scanError.includes('folder not found'), 'missing folder reported, not crashed')
175
-
176
- // 10) setFolder 到真实文件夹(含子目录 md)-> 重新扫描成功
177
- const goodFolder = await handlers['setFolder']({ path: skillsFolder })
178
- assert(goodFolder.scanned === true && goodFolder.scanError === '', 'rescan real folder ok')
179
- assert(goodFolder.skills.length >= 2, 'rescanned skills found: ' + goodFolder.skills.length)
180
-
181
- // 11) invalidate 被调用(选择变更 + 扫描完成都会触发)
182
- assert(invalidateCount > 0, 'provider invalidate called on changes: ' + invalidateCount)
183
-
184
- console.log('--- verification done ---')
185
- }
186
-
187
- main().catch((error) => {
188
- console.error('RUNNER ERROR:', error)
189
- process.exit(1)
190
- })