@weibaohui/skills-management 0.1.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 +79 -0
- package/client/bundle.js +1380 -0
- package/client/components/skills/helpers.ts +108 -0
- package/client/hooks/index.ts +10 -0
- package/client/hooks/useSkillCatalog.ts +201 -0
- package/client/hooks/useSkillDetail.ts +89 -0
- package/client/hooks/useSkillInstall.ts +74 -0
- package/client/index.js +1366 -0
- package/cordis.patch.yml +12 -0
- package/package.json +61 -0
- package/src/index.js +1073 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-plugin-skills-management — Host half
|
|
5
|
+
*
|
|
6
|
+
* Two skill universes, one API:
|
|
7
|
+
* - Market: ntd-style bundled collections (git checkouts of GitHub skill
|
|
8
|
+
* repos). Read-only, install copies into the user library.
|
|
9
|
+
* - Executors: every coding agent's on-machine skills directory
|
|
10
|
+
* (`~/.claude/skills`, `~/.agents/skills`, …), following the ntd source
|
|
11
|
+
* table. Scanned for display/detail; deletable per source unless marked
|
|
12
|
+
* read-only (`agents`); any executor skill can be copied into the dsh
|
|
13
|
+
* user library so the `skill` tool can call it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { createReadStream } = require('node:fs')
|
|
17
|
+
const { execFile, spawn } = require('node:child_process')
|
|
18
|
+
const { randomUUID } = require('node:crypto')
|
|
19
|
+
const fsP = require('node:fs/promises')
|
|
20
|
+
const { basename, join, relative, resolve, sep } = require('node:path')
|
|
21
|
+
const { homedir } = require('node:os')
|
|
22
|
+
const YAML = require('yaml')
|
|
23
|
+
// settings 服务要求 schemastery schema(可调用 + toJSON;zod 不兼容,register 会抛错被吞)。
|
|
24
|
+
// 宿主沙箱内解析打包依赖可能抛 ERR_INTERNAL_ASSERTION(.pnpm 软链),因此优先沿
|
|
25
|
+
// dsh 全局安装取 settings 服务自用的那份副本,本地开发/测试再退回标准 require。
|
|
26
|
+
function loadSchemastery() {
|
|
27
|
+
const errors = []
|
|
28
|
+
const { createRequire } = require('node:module')
|
|
29
|
+
for (const prefix of [process.env.DSH_GLOBAL_PREFIX, join(homedir(), '.local')].filter(Boolean)) {
|
|
30
|
+
const hostCopy = join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery', 'lib', 'index.cjs')
|
|
31
|
+
try { return createRequire(hostCopy)(hostCopy) } catch (e) { errors.push(String(e && e.code || e)) }
|
|
32
|
+
}
|
|
33
|
+
try { return require('@deepseek-ai/schemastery') } catch (e) { errors.push(String(e && e.code || e)) }
|
|
34
|
+
if (process.env.SKILLS_SETTINGS_DEBUG) console.warn(`[skills-management] schemastery unavailable: ${errors.join(' | ')}`)
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const Schema = loadSchemastery()
|
|
38
|
+
|
|
39
|
+
const DEFAULT_MARKET_DIRS = [join(homedir(), '.ntd', 'bundled', 'skills')]
|
|
40
|
+
const MARKET_SCAN_SKIP = new Set(['.git', 'node_modules'])
|
|
41
|
+
const RANK_INSTALLED = 100
|
|
42
|
+
const RANK_MARKET = 500
|
|
43
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
44
|
+
const DESCRIPTION_LIMIT = 140
|
|
45
|
+
// 同款正则见 skill/skill/src/index.ts SKILL_NAME —— 不合规的候选会让 registry 抛错
|
|
46
|
+
const KEBAB_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Known on-machine skill sources (executor → skills dir), ported from ntd's
|
|
50
|
+
* `ALL_SKILL_SOURCES` table plus this machine's ZCode CLI. `sub` is relative
|
|
51
|
+
* to $HOME; `dsh` is special — its root is the plugin's installedDir.
|
|
52
|
+
*/
|
|
53
|
+
const EXECUTOR_DEFS = [
|
|
54
|
+
{ key: 'dsh', label: 'DSH' },
|
|
55
|
+
{ key: 'claudecode', label: 'Claude Code', sub: '.claude/skills' },
|
|
56
|
+
{ key: 'zcode', label: 'ZCode', sub: '.zcode/skills' },
|
|
57
|
+
{ key: 'codex', label: 'Codex', sub: '.codex/skills' },
|
|
58
|
+
{ key: 'opencode', label: 'OpenCode', sub: '.opencode/skills' },
|
|
59
|
+
{ key: 'codebuddy', label: 'CodeBuddy', sub: '.codebuddy/skills' },
|
|
60
|
+
{ key: 'atomcode', label: 'AtomCode', sub: '.atomcode/skills' },
|
|
61
|
+
{ key: 'hermes', label: 'Hermes', sub: '.hermes/skills' },
|
|
62
|
+
{ key: 'kimi', label: 'Kimi', sub: '.kimi/skills' },
|
|
63
|
+
{ key: 'mobilecoder', label: 'MobileCoder', sub: '.mobile-coder/skills' },
|
|
64
|
+
{ key: 'codewhale', label: 'Codewhale', sub: '.codewhale/skills' },
|
|
65
|
+
{ key: 'kilo', label: 'Kilo', sub: '.kilo/skills' },
|
|
66
|
+
{ key: 'pi', label: 'Pi', sub: '.pi/skills' },
|
|
67
|
+
{ key: 'mimo', label: 'Mimo', sub: '.local/share/mimocode/skills' },
|
|
68
|
+
{ key: 'zhanlu', label: 'ZhanLu', sub: '.local/share/zhanlu/skills' },
|
|
69
|
+
// agents 共享池曾是只读来源;治理键开关(disable-model-invocation)覆盖该根后
|
|
70
|
+
// "只读"名不副实——与 dsh 的 user-agents 内置根对齐,按普通可写来源对待。
|
|
71
|
+
{ key: 'agents', label: 'Agents', sub: '.agents/skills' },
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/** Target directory name when installing a (possibly nested) skill name. */
|
|
75
|
+
function installDirName(fullName) {
|
|
76
|
+
const parts = String(fullName === undefined || fullName === null ? '' : fullName).split('/')
|
|
77
|
+
return parts[parts.length - 1]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Absolute path with the $HOME prefix folded to `~` (no username leaks in UI). */
|
|
81
|
+
function displayPath(p) {
|
|
82
|
+
const home = homedir()
|
|
83
|
+
if (p === home) return '~'
|
|
84
|
+
if (p.startsWith(home + sep)) return '~' + p.slice(home.length)
|
|
85
|
+
return p
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractFrontmatter(content) {
|
|
89
|
+
const lines = content.split(/\r?\n/)
|
|
90
|
+
if (lines[0] === undefined || lines[0].trim() !== '---') return undefined
|
|
91
|
+
const yamlLines = []
|
|
92
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
93
|
+
const line = lines[index]
|
|
94
|
+
if (line.trim() === '---') return yamlLines.join('\n')
|
|
95
|
+
yamlLines.push(line)
|
|
96
|
+
}
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseSkillMd(content) {
|
|
101
|
+
const yamlText = extractFrontmatter(content)
|
|
102
|
+
if (yamlText === undefined) return { meta: {}, body: content }
|
|
103
|
+
let meta = {}
|
|
104
|
+
try {
|
|
105
|
+
const parsed = YAML.parse(yamlText)
|
|
106
|
+
if (parsed !== null && typeof parsed === 'object') meta = parsed
|
|
107
|
+
} catch {}
|
|
108
|
+
const lines = content.split(/\r?\n/)
|
|
109
|
+
let closer = -1
|
|
110
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
111
|
+
if (lines[index].trim() === '---') { closer = index; break }
|
|
112
|
+
}
|
|
113
|
+
const body = closer >= 0 ? lines.slice(closer + 1).join('\n').replace(/^\r?\n/, '') : content
|
|
114
|
+
return { meta, body }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildEntry(root, dir, stat) {
|
|
118
|
+
return { root, dir, relPath: relative(root, dir).split(sep).join('/'), stat }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function scanSkillDirs(root, current, out, visited) {
|
|
122
|
+
let entries
|
|
123
|
+
try { entries = await fsP.readdir(current, { withFileTypes: true }) }
|
|
124
|
+
catch { return }
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (MARKET_SCAN_SKIP.has(entry.name)) continue
|
|
127
|
+
const dir = join(current, entry.name)
|
|
128
|
+
// Follow symlinks: executor skills dirs routinely symlink entries from a
|
|
129
|
+
// shared pool (~/.agents/skills); Dirent.isDirectory() would miss them.
|
|
130
|
+
let dirStat
|
|
131
|
+
try { dirStat = await fsP.stat(dir) } catch { continue }
|
|
132
|
+
if (!dirStat.isDirectory()) continue
|
|
133
|
+
let real
|
|
134
|
+
try { real = await fsP.realpath(dir) } catch { continue }
|
|
135
|
+
if (visited.has(real)) continue // symlink cycle guard
|
|
136
|
+
visited.add(real)
|
|
137
|
+
let hasSkillMd = false, skillMdStat
|
|
138
|
+
try { skillMdStat = await fsP.stat(join(dir, 'SKILL.md')); hasSkillMd = skillMdStat.isFile() } catch { hasSkillMd = false }
|
|
139
|
+
if (hasSkillMd) { out.push(buildEntry(root, dir, skillMdStat)) }
|
|
140
|
+
else { await scanSkillDirs(root, dir, out, visited) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function scanRoot(root) {
|
|
145
|
+
const out = []
|
|
146
|
+
let real
|
|
147
|
+
try { await fsP.access(root) } catch { return out }
|
|
148
|
+
try { real = await fsP.realpath(root) } catch { return out }
|
|
149
|
+
await scanSkillDirs(root, root, out, new Set([real]))
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readSkillEntry(entry) {
|
|
154
|
+
const content = await fsP.readFile(join(entry.dir, 'SKILL.md'), 'utf8')
|
|
155
|
+
const { meta, body } = parseSkillMd(content)
|
|
156
|
+
const name = typeof meta.name === 'string' && meta.name !== '' ? meta.name : basename(entry.dir)
|
|
157
|
+
return {
|
|
158
|
+
entry,
|
|
159
|
+
name,
|
|
160
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
161
|
+
keywords: Array.isArray(meta.keywords) ? meta.keywords : [],
|
|
162
|
+
version: typeof meta.version === 'string' ? meta.version : undefined,
|
|
163
|
+
author: typeof meta.author === 'string' ? meta.author : undefined,
|
|
164
|
+
license: typeof meta.license === 'string' ? meta.license : undefined,
|
|
165
|
+
meta, body,
|
|
166
|
+
modifiedAt: entry.stat !== undefined ? entry.stat.mtime.toISOString() : undefined,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function countFilesAndSize(dir) {
|
|
171
|
+
let fileCount = 0, totalSize = 0
|
|
172
|
+
const walk = async (current) => {
|
|
173
|
+
const entries = await fsP.readdir(current, { withFileTypes: true })
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
const entryPath = join(current, entry.name)
|
|
176
|
+
// stat follows symlinks so linked files/dirs count toward the skill
|
|
177
|
+
let stat = await fsP.stat(entryPath).catch(() => undefined)
|
|
178
|
+
if (stat === undefined) continue
|
|
179
|
+
if (stat.isDirectory()) { await walk(entryPath) }
|
|
180
|
+
else if (stat.isFile()) {
|
|
181
|
+
fileCount += 1
|
|
182
|
+
totalSize += stat.size
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
await walk(dir)
|
|
187
|
+
return { fileCount, totalSize }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function copyDir(from, to) {
|
|
191
|
+
await fsP.mkdir(to, { recursive: true })
|
|
192
|
+
const entries = await fsP.readdir(from, { withFileTypes: true })
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
if (entry.name === '.git') continue
|
|
195
|
+
const source = join(from, entry.name), target = join(to, entry.name)
|
|
196
|
+
// Follow symlinks and materialize their targets: installs must be
|
|
197
|
+
// self-contained (a linked references/ dir cannot dangle later).
|
|
198
|
+
let stat
|
|
199
|
+
try { stat = await fsP.stat(source) } catch { continue }
|
|
200
|
+
if (stat.isDirectory()) { await copyDir(source, target) }
|
|
201
|
+
else if (stat.isFile()) { await fsP.copyFile(source, target) }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function resolveSkillDir(root, fullName) {
|
|
206
|
+
if (fullName === '' || fullName.includes('..') || fullName.includes('\\') || fullName.startsWith('/')) {
|
|
207
|
+
throw new Error('invalid skill name')
|
|
208
|
+
}
|
|
209
|
+
const dir = resolve(root, fullName)
|
|
210
|
+
if (!dir.startsWith(resolve(root) + sep)) throw new Error('invalid skill name: escapes root')
|
|
211
|
+
let stat
|
|
212
|
+
try { stat = await fsP.stat(join(dir, 'SKILL.md')) }
|
|
213
|
+
catch (e) {
|
|
214
|
+
if (e.code === 'ENOENT' || e.code === 'ENOTDIR') throw new Error(`skill '${fullName}' not found`)
|
|
215
|
+
throw e
|
|
216
|
+
}
|
|
217
|
+
if (!stat.isFile()) throw new Error('not a skill directory')
|
|
218
|
+
return dir
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Shared route helpers ────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function validSkillName(name) {
|
|
224
|
+
return typeof name === 'string' && name !== '' && !name.includes('..') && !name.includes('\\') && !name.startsWith('/')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Resolve `<root>/<name>` to an existing skill dir under one source root. */
|
|
228
|
+
async function findDirUnderRoot(root, fullName, where) {
|
|
229
|
+
try { return await resolveSkillDir(root, fullName) }
|
|
230
|
+
catch (e) {
|
|
231
|
+
if (String(e && e.message).includes('not found')) throw new Error(`skill '${fullName}' not found in ${where}`)
|
|
232
|
+
throw e
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function sendSkillFile(res, skillDir, relPath, contentType) {
|
|
237
|
+
const target = resolve(skillDir, relPath)
|
|
238
|
+
const skillRoot = resolve(skillDir)
|
|
239
|
+
if (!target.startsWith(skillRoot + sep)) throw new Error('invalid file path')
|
|
240
|
+
const stat = await fsP.stat(target)
|
|
241
|
+
if (!stat.isFile()) throw new Error('file not found')
|
|
242
|
+
res.writeHead(200, {
|
|
243
|
+
'content-type': contentType !== undefined && contentType !== '' ? contentType : 'application/octet-stream',
|
|
244
|
+
'content-length': stat.size,
|
|
245
|
+
})
|
|
246
|
+
const stream = createReadStream(target)
|
|
247
|
+
stream.pipe(res)
|
|
248
|
+
await new Promise((fulfil, reject) => {
|
|
249
|
+
stream.on('error', reject)
|
|
250
|
+
res.on('close', () => fulfil())
|
|
251
|
+
stream.on('end', () => fulfil())
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function walkFiles(base, current, files = []) {
|
|
256
|
+
const entries = await fsP.readdir(current, { withFileTypes: true })
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
const entryPath = join(current, entry.name)
|
|
259
|
+
let stat = await fsP.stat(entryPath).catch(() => undefined) // follows symlinks
|
|
260
|
+
if (stat === undefined) continue
|
|
261
|
+
if (stat.isDirectory()) { await walkFiles(base, entryPath, files) }
|
|
262
|
+
else if (stat.isFile()) {
|
|
263
|
+
files.push({ path: relative(base, entryPath).split(sep).join('/'), size: stat.size, modifiedAt: stat.mtime.toISOString() })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return files
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readJsonBody(req) {
|
|
270
|
+
return new Promise((fulfil, reject) => {
|
|
271
|
+
let size = 0, chunks = []
|
|
272
|
+
req.on('data', (chunk) => {
|
|
273
|
+
size += chunk.length
|
|
274
|
+
if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
|
|
275
|
+
chunks.push(chunk)
|
|
276
|
+
})
|
|
277
|
+
req.on('end', () => {
|
|
278
|
+
try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
|
|
279
|
+
catch (error) { reject(new Error(`invalid JSON body: ${error && error.message}`)) }
|
|
280
|
+
})
|
|
281
|
+
req.on('error', reject)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function sendJson(res, status, payload) {
|
|
286
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
287
|
+
res.end(JSON.stringify(payload))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function flagValue(meta, key) {
|
|
291
|
+
const value = meta[key]
|
|
292
|
+
if (typeof value === 'boolean') return value
|
|
293
|
+
if (typeof value === 'string') {
|
|
294
|
+
const lowered = value.trim().toLowerCase()
|
|
295
|
+
if (['true', 'yes', 'on', '1'].includes(lowered)) return true
|
|
296
|
+
if (['false', 'no', 'off', '0'].includes(lowered)) return false
|
|
297
|
+
}
|
|
298
|
+
return undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function invocationPolicy(meta) {
|
|
302
|
+
return { modelInvocable: flagValue(meta, 'disable-model-invocation') !== true, userInvocable: flagValue(meta, 'user-invocable') !== false }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function truncateDescription(text) {
|
|
306
|
+
if (typeof text !== 'string') return ''
|
|
307
|
+
const single = text.split(/\r?\n/)[0]
|
|
308
|
+
return single.length > DESCRIPTION_LIMIT ? single.slice(0, DESCRIPTION_LIMIT) + '…' : single
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function expandTilde(p) {
|
|
312
|
+
return p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? join(homedir(), p.slice(2)) : p
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 切换 dsh 原生治理键 `disable-model-invocation`(docs/subsystems/skills.md)。
|
|
317
|
+
* modelInvocable=true 时移除该键;false 时写入 true。其余 frontmatter 键与正文原样保留。
|
|
318
|
+
*/
|
|
319
|
+
function setModelInvocable(content, modelInvocable) {
|
|
320
|
+
const lines = String(content || '').split(/\r?\n/)
|
|
321
|
+
if (lines[0] === undefined || lines[0].trim() !== '---') {
|
|
322
|
+
return modelInvocable ? content : `---\ndisable-model-invocation: true\n---\n\n${content}`
|
|
323
|
+
}
|
|
324
|
+
let closer = -1
|
|
325
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
326
|
+
if (lines[i].trim() === '---') { closer = i; break }
|
|
327
|
+
}
|
|
328
|
+
if (closer === -1) return content
|
|
329
|
+
let kept = lines.slice(1, closer).filter((l) => !/^disable-model-invocation\s*:/.test(l.trim()))
|
|
330
|
+
if (!modelInvocable) kept = [...kept, 'disable-model-invocation: true']
|
|
331
|
+
return [...lines.slice(0, 1), ...kept, ...lines.slice(closer)].join('\n')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function atomicWriteJs(file, content) {
|
|
335
|
+
await fsP.mkdir(join(file, '..'), { recursive: true })
|
|
336
|
+
const temp = join(join(file, '..'), `.${randomUUID()}.tmp`)
|
|
337
|
+
await fsP.writeFile(temp, content, 'utf8')
|
|
338
|
+
await fsP.rename(temp, file)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Market git sync (ntd git_sync semantics: clone --depth 1 first, then
|
|
342
|
+
// fetch + reset --hard so the remote always wins and local damage heals) ──
|
|
343
|
+
|
|
344
|
+
function gitExec(binary, args, cwd) {
|
|
345
|
+
return new Promise((fulfil, reject) => {
|
|
346
|
+
execFile(binary, args, { cwd, timeout: 10 * 60 * 1000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
347
|
+
if (error) {
|
|
348
|
+
const tail = String(stderr || error.message || '').split(/\r?\n/).filter(Boolean).slice(-3).join(' ')
|
|
349
|
+
reject(new Error(`git ${args[0]}: ${tail || error.message}`))
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
fulfil(String(stdout).trim())
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function gitAvailable(binary) {
|
|
358
|
+
try { await gitExec(binary, ['--version']); return true } catch { return false }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function gitCurrentCommit(binary, repo) {
|
|
362
|
+
try { return await gitExec(binary, ['rev-parse', 'HEAD'], repo) } catch { return undefined }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function gitRemoteCommit(binary, repo, remote, branch) {
|
|
366
|
+
try {
|
|
367
|
+
const out = await gitExec(binary, ['ls-remote', '--heads', remote, branch], repo)
|
|
368
|
+
return out.split(/\s+/)[0] || undefined
|
|
369
|
+
} catch { return undefined }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Embed an access token in an https remote URL (gitcode/oauth2 style).
|
|
373
|
+
* Credentials stay out of .git/config — every remote-touching command
|
|
374
|
+
* receives the authed URL directly and nothing is persisted. */
|
|
375
|
+
function authedUrl(url, token) {
|
|
376
|
+
if (!token) return url
|
|
377
|
+
return String(url).replace(/^(https?:\/\/)([^@/]+@)?/, `$1oauth2:${encodeURIComponent(token)}@`)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Clone (first time) or fetch+reset (update); remote branch is truth. */
|
|
381
|
+
async function gitSyncRepo(binary, url, branch, repoDir, token) {
|
|
382
|
+
const remote = authedUrl(url, token)
|
|
383
|
+
let repoExists = false
|
|
384
|
+
try { await fsP.access(join(repoDir, '.git')); repoExists = true } catch { repoExists = false }
|
|
385
|
+
if (!repoExists) {
|
|
386
|
+
await fsP.rm(repoDir, { recursive: true, force: true })
|
|
387
|
+
await fsP.mkdir(join(repoDir, '..'), { recursive: true })
|
|
388
|
+
await gitExec(binary, ['clone', '-b', branch, '--depth', '1', remote, repoDir])
|
|
389
|
+
return { isFirstClone: true, hasUpdates: true, before: undefined, after: await gitCurrentCommit(binary, repoDir) }
|
|
390
|
+
}
|
|
391
|
+
const before = await gitCurrentCommit(binary, repoDir)
|
|
392
|
+
await gitExec(binary, ['fetch', remote, branch], repoDir)
|
|
393
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir)
|
|
394
|
+
const after = await gitCurrentCommit(binary, repoDir)
|
|
395
|
+
return { isFirstClone: false, hasUpdates: before !== after, before, after }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const DEFAULT_MARKET_SYNC = {
|
|
399
|
+
url: 'https://gitcode.com/weibaohui/ntd-resource.git',
|
|
400
|
+
branch: 'main',
|
|
401
|
+
gitBinary: 'git',
|
|
402
|
+
autoSync: true, // periodic: sync when lastSyncAt is older than a day
|
|
403
|
+
syncOnStartup: true,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** User-settings namespace persisted through the host ctx.settings service
|
|
407
|
+
* (local provider → $DSH_HOME/settings.yaml). Falls back to an in-memory
|
|
408
|
+
* override sheet when the service is absent (tests, minimal compositions). */
|
|
409
|
+
// 命名空间必须匹配 /^[a-z][a-z0-9-]*$/ —— 点号形式会被 settings 写入通道拒绝
|
|
410
|
+
const MARKET_SETTINGS_NS = 'skills-management-market'
|
|
411
|
+
|
|
412
|
+
function marketSettingsSchema() {
|
|
413
|
+
if (!Schema) return null
|
|
414
|
+
return Schema.object({
|
|
415
|
+
url: Schema.string(),
|
|
416
|
+
branch: Schema.string(),
|
|
417
|
+
gitBinary: Schema.string(),
|
|
418
|
+
repoDir: Schema.string(),
|
|
419
|
+
autoSync: Schema.boolean(),
|
|
420
|
+
syncOnStartup: Schema.boolean(),
|
|
421
|
+
token: Schema.string(),
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function mergeMarketSync(config, overrides) {
|
|
426
|
+
const cfg = (config && config.marketSync && typeof config.marketSync === 'object') ? config.marketSync : {}
|
|
427
|
+
return { ...DEFAULT_MARKET_SYNC, ...cfg, ...(overrides || {}) }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Share-run jobs: real execution via the official headless channel
|
|
431
|
+
// (`dsh --profile headless "<task>"`, cwd = the skill directory — the
|
|
432
|
+
// workspace, session and model loop are owned by that one-shot process). ──
|
|
433
|
+
|
|
434
|
+
const SHARE_RUN_TIMEOUT_MS = 30 * 60 * 1000
|
|
435
|
+
const SHARE_RUN_OUTPUT_CAP = 256 * 1024
|
|
436
|
+
|
|
437
|
+
/** In-process run: drive the same Agent services the web app uses and
|
|
438
|
+
* stream assistant/chunk tokens + tool calls into the job's output as they
|
|
439
|
+
* happen (headless prints only the final message — no live channel there).
|
|
440
|
+
* Mirrors packages/bundle/headless/src/index.ts run(). */
|
|
441
|
+
async function runShareInProcess(services, { prompt, dir, job, logger }) {
|
|
442
|
+
const selection = services.agentDefaultModel.currentSelection()
|
|
443
|
+
const sessionId = 'session-' + randomUUID()
|
|
444
|
+
job.sessionId = sessionId
|
|
445
|
+
const { agent } = await services.agents.create({
|
|
446
|
+
sessionId,
|
|
447
|
+
// 标准预设:不带显式选择会继承用户默认(如 Solo Thinking 只有
|
|
448
|
+
// thinking/notify 工具),读文件/调 API 都做不了
|
|
449
|
+
meta: { cwd: dir, agentPreset: 'standard' },
|
|
450
|
+
agentOptions: { provider: selection.provider, model: selection.model },
|
|
451
|
+
})
|
|
452
|
+
await agent.whenIdle()
|
|
453
|
+
const firstSeq = agent.session.seq
|
|
454
|
+
const seen = new Set()
|
|
455
|
+
const liveLine = (text) => {
|
|
456
|
+
job.output = (job.output + text).slice(-SHARE_RUN_OUTPUT_CAP)
|
|
457
|
+
}
|
|
458
|
+
const pump = () => {
|
|
459
|
+
for (const ev of agent.session.events) {
|
|
460
|
+
if (ev.seq < firstSeq || seen.has(ev.seq)) continue
|
|
461
|
+
seen.add(ev.seq)
|
|
462
|
+
const d = ev.data || {}
|
|
463
|
+
if (ev.type === 'assistant/chunk' && d.chunk && d.chunk.type === 'text' && d.chunk.text) {
|
|
464
|
+
liveLine(d.chunk.text)
|
|
465
|
+
} else if (ev.type === 'tool/call') {
|
|
466
|
+
liveLine('\n[tool] ' + d.name + ' ')
|
|
467
|
+
} else if (ev.type === 'assistant/message') {
|
|
468
|
+
liveLine('\n')
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const timer = setInterval(pump, 300)
|
|
473
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
474
|
+
try {
|
|
475
|
+
agent.followup({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })
|
|
476
|
+
await agent.whenIdle()
|
|
477
|
+
} finally {
|
|
478
|
+
clearInterval(timer)
|
|
479
|
+
pump()
|
|
480
|
+
}
|
|
481
|
+
try { await services.sessions.flush(agent.session) } catch {}
|
|
482
|
+
job.status = 'done'
|
|
483
|
+
job.code = 0
|
|
484
|
+
return job
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createShareRunJob({ binary, prompt, dir, jobs, logger, services }) {
|
|
488
|
+
const id = 'sr' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
489
|
+
const job = { id, status: 'running', startedAt: new Date().toISOString(), dir, promptHead: prompt.slice(0, 80), output: '', code: null }
|
|
490
|
+
jobs.set(id, job)
|
|
491
|
+
if (services && services.agents && services.agentDefaultModel) {
|
|
492
|
+
runShareInProcess(services, { prompt, dir, job, logger })
|
|
493
|
+
.catch(e => { job.status = 'error'; job.output = (job.output + '\n' + String(e && e.message)).slice(-SHARE_RUN_OUTPUT_CAP) })
|
|
494
|
+
return job
|
|
495
|
+
}
|
|
496
|
+
let child
|
|
497
|
+
try {
|
|
498
|
+
child = spawn(binary, ['--profile', 'headless', prompt], { cwd: dir })
|
|
499
|
+
} catch (e) {
|
|
500
|
+
job.status = 'error'
|
|
501
|
+
job.output = String(e && e.message)
|
|
502
|
+
return job
|
|
503
|
+
}
|
|
504
|
+
const append = (chunk) => {
|
|
505
|
+
job.output = (job.output + String(chunk)).slice(-SHARE_RUN_OUTPUT_CAP)
|
|
506
|
+
}
|
|
507
|
+
child.stdout && child.stdout.on('data', append)
|
|
508
|
+
child.stderr && child.stderr.on('data', append)
|
|
509
|
+
const timer = setTimeout(() => {
|
|
510
|
+
try { child.kill('SIGKILL') } catch {}
|
|
511
|
+
job.status = 'error'
|
|
512
|
+
job.output += '\n[killed: timeout]'
|
|
513
|
+
}, SHARE_RUN_TIMEOUT_MS)
|
|
514
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
515
|
+
child.on('error', (e) => { clearTimeout(timer); job.status = 'error'; append('\n' + String(e && e.message)) })
|
|
516
|
+
child.on('close', (code) => {
|
|
517
|
+
clearTimeout(timer)
|
|
518
|
+
if (job.status === 'running') {
|
|
519
|
+
job.status = code === 0 ? 'done' : 'error'
|
|
520
|
+
job.code = code
|
|
521
|
+
}
|
|
522
|
+
logger.info && logger.info(`skills-management: share run ${id} ${job.status} (code ${code})`)
|
|
523
|
+
})
|
|
524
|
+
return job
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function contentTypeFor(p) {
|
|
528
|
+
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase()
|
|
529
|
+
const map = { md: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', json: 'application/json; charset=utf-8', js: 'text/javascript', mjs: 'text/javascript', ts: 'text/typescript', tsx: 'text/typescript', css: 'text/css', html: 'text/html', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', yaml: 'text/yaml', yml: 'text/yaml' }
|
|
530
|
+
return map[ext]
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
module.exports = {
|
|
534
|
+
name: 'skills-management',
|
|
535
|
+
inject: ['skills', 'webServer', 'settings'],
|
|
536
|
+
__internals: { extractFrontmatter, parseSkillMd, invocationPolicy, installDirName, EXECUTOR_DEFS },
|
|
537
|
+
|
|
538
|
+
apply(ctx, config = {}) {
|
|
539
|
+
// Explicit marketDirs config wins; otherwise the scan follows the
|
|
540
|
+
// runtime-configurable repo dir (<repoDir>/skills) so moving the checkout
|
|
541
|
+
// in settings switches the market without touching cordis.yml.
|
|
542
|
+
const configMarketDirs = config.marketDirs !== undefined
|
|
543
|
+
? config.marketDirs.map((d) => resolve(expandTilde(d)))
|
|
544
|
+
: undefined
|
|
545
|
+
const effectiveRepoDir = () => {
|
|
546
|
+
const eff = marketSettings()
|
|
547
|
+
return resolve(expandTilde(
|
|
548
|
+
typeof eff.repoDir === 'string' && eff.repoDir !== '' ? eff.repoDir
|
|
549
|
+
: config.marketRepoDir !== undefined ? config.marketRepoDir
|
|
550
|
+
: join(homedir(), '.ntd', 'bundled')))
|
|
551
|
+
}
|
|
552
|
+
const marketRoots = () => configMarketDirs !== undefined ? configMarketDirs : [join(effectiveRepoDir(), 'skills')]
|
|
553
|
+
const marketDirs = marketRoots // scan/install/locate call sites read through this
|
|
554
|
+
const installedDir = resolve(expandTilde(config.installedDir !== undefined ? config.installedDir : process.env.DSH_HOME ? join(process.env.DSH_HOME, 'skills') : join(homedir(), '.dsh', 'skills')))
|
|
555
|
+
const providerName = config.providerName !== undefined ? config.providerName : 'ntd-skills'
|
|
556
|
+
// 市场库存(数几千条)默认不进模型目录 available_skills —— 只作为可浏览/可安装的货架。
|
|
557
|
+
// 装到用户库(installedDir)后才对模型可见。config.marketModelInvocable: true 可恢复旧行为。
|
|
558
|
+
const marketModelInvocable = config.marketModelInvocable === true
|
|
559
|
+
|
|
560
|
+
// ── Executor (on-machine source) rows ──
|
|
561
|
+
// dsh first (its root is installedDir); then known defs minus disabled;
|
|
562
|
+
// then user extras. `executorDirs` overrides per-key roots, which also
|
|
563
|
+
// makes scans testable without touching the real $HOME.
|
|
564
|
+
const executorDirsOverride = config.executorDirs !== undefined && config.executorDirs !== null && typeof config.executorDirs === 'object' ? config.executorDirs : {}
|
|
565
|
+
const disabledExecutors = new Set(Array.isArray(config.disabledExecutors) ? config.disabledExecutors : [])
|
|
566
|
+
const seenKeys = new Set()
|
|
567
|
+
const executorRows = []
|
|
568
|
+
for (const def of EXECUTOR_DEFS) {
|
|
569
|
+
if (disabledExecutors.has(def.key)) continue
|
|
570
|
+
let root
|
|
571
|
+
if (def.key === 'dsh') root = installedDir
|
|
572
|
+
else if (executorDirsOverride[def.key] !== undefined) root = resolve(expandTilde(String(executorDirsOverride[def.key])))
|
|
573
|
+
else root = def.sub !== undefined ? join(homedir(), ...def.sub.split('/')) : undefined
|
|
574
|
+
if (seenKeys.has(def.key)) continue
|
|
575
|
+
seenKeys.add(def.key)
|
|
576
|
+
executorRows.push({ key: def.key, label: def.label, root, readOnly: def.readOnly === true })
|
|
577
|
+
}
|
|
578
|
+
for (const extra of Array.isArray(config.extraExecutors) ? config.extraExecutors : []) {
|
|
579
|
+
if (extra === null || typeof extra !== 'object') continue
|
|
580
|
+
if (typeof extra.key !== 'string' || extra.key === '') continue
|
|
581
|
+
if (typeof extra.dir !== 'string' || extra.dir === '') continue
|
|
582
|
+
if (seenKeys.has(extra.key)) continue
|
|
583
|
+
seenKeys.add(extra.key)
|
|
584
|
+
executorRows.push({
|
|
585
|
+
key: extra.key,
|
|
586
|
+
label: typeof extra.label === 'string' && extra.label !== '' ? extra.label : extra.key,
|
|
587
|
+
root: resolve(expandTilde(extra.dir)),
|
|
588
|
+
readOnly: extra.readOnly === true,
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function discoverAll() {
|
|
593
|
+
const market = [], installed = []
|
|
594
|
+
for (const root of marketDirs()) {
|
|
595
|
+
for (const entry of await scanRoot(root)) {
|
|
596
|
+
try { market.push(await readSkillEntry(entry)) }
|
|
597
|
+
catch (e) { ctx.logger.warn(`skills-management: skipping ${entry.dir}: ${e && e.message}`) }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
for (const entry of await scanRoot(installedDir)) {
|
|
601
|
+
try { installed.push(await readSkillEntry(entry)) }
|
|
602
|
+
catch (e) { ctx.logger.warn(`skills-management: skipping ${entry.dir}: ${e && e.message}`) }
|
|
603
|
+
}
|
|
604
|
+
return { market, installed }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* One executor row → summary + flat skill list (ntd `discover_skills_for`).
|
|
609
|
+
* With `countsOnly` the expensive per-skill dir walks are skipped and
|
|
610
|
+
* `skills` stays undefined — callers get `skillCount` only.
|
|
611
|
+
*/
|
|
612
|
+
async function scanExecutor(row, countsOnly = false) {
|
|
613
|
+
const summary = { key: row.key, label: row.label, dir: displayPath(row.root), dirExists: false, readOnly: row.readOnly, skillCount: 0 }
|
|
614
|
+
if (!countsOnly) summary.skills = []
|
|
615
|
+
try { await fsP.access(row.root) } catch { return summary }
|
|
616
|
+
summary.dirExists = true
|
|
617
|
+
for (const entry of await scanRoot(row.root)) {
|
|
618
|
+
try {
|
|
619
|
+
const read = await readSkillEntry(entry)
|
|
620
|
+
// ntd naming: nested skill whose frontmatter name equals its dir
|
|
621
|
+
// basename keeps the category path as display name.
|
|
622
|
+
const listed = entry.relPath.includes('/') && read.name === basename(entry.dir) ? entry.relPath : read.name
|
|
623
|
+
summary.skillCount += 1
|
|
624
|
+
if (countsOnly) continue
|
|
625
|
+
const { fileCount, totalSize } = await countFilesAndSize(entry.dir)
|
|
626
|
+
summary.skills.push({ name: listed, relPath: entry.relPath, description: truncateDescription(read.description), keywords: read.keywords, version: read.version, author: read.author, fileCount, totalSize, modifiedAt: read.modifiedAt, modelInvocable: invocationPolicy(read.meta).modelInvocable })
|
|
627
|
+
} catch (e) { ctx.logger.warn(`skills-management: skipping ${entry.dir}: ${e && e.message}`) }
|
|
628
|
+
}
|
|
629
|
+
if (summary.skills !== undefined) {
|
|
630
|
+
summary.skills.sort((a, b) => {
|
|
631
|
+
const la = a.name.toLowerCase(), lb = b.name.toLowerCase()
|
|
632
|
+
return la < lb ? -1 : la > lb ? 1 : 0
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
return summary
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function findExecutorRow(key) {
|
|
639
|
+
return executorRows.find((row) => row.key === key)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Locate a named skill dir either scoped to one executor source or via
|
|
644
|
+
* the legacy auto path (installed library first, then markets).
|
|
645
|
+
* Returns `{ dir, executorKey|null, isInstalled }`.
|
|
646
|
+
*/
|
|
647
|
+
async function locateNamedSkillDir(name, executorKey) {
|
|
648
|
+
if (executorKey !== undefined && executorKey !== null && executorKey !== '' && executorKey !== 'auto') {
|
|
649
|
+
const row = findExecutorRow(executorKey)
|
|
650
|
+
if (row === undefined) throw new Error(`unknown executor '${executorKey}'`)
|
|
651
|
+
const dir = await findDirUnderRoot(row.root, name, `${row.label} (${row.key})`)
|
|
652
|
+
return { dir, executorKey: row.key, isInstalled: row.key === 'dsh' }
|
|
653
|
+
}
|
|
654
|
+
try { return { dir: await resolveSkillDir(installedDir, name), executorKey: 'dsh', isInstalled: true } }
|
|
655
|
+
catch { /* fall through to market roots */ }
|
|
656
|
+
for (const root of marketDirs()) {
|
|
657
|
+
try { return { dir: await resolveSkillDir(root, name), executorKey: null, isInstalled: false } }
|
|
658
|
+
catch (e) { if (!String(e && e.message).includes('not found')) throw e }
|
|
659
|
+
}
|
|
660
|
+
throw new Error(`skill '${name}' not found`)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Copy any source skill dir into the dsh user library and refresh. */
|
|
664
|
+
async function copyIntoLibrary(sourceDir, shortName, overwrite) {
|
|
665
|
+
const target = join(installedDir, shortName)
|
|
666
|
+
if (!overwrite) {
|
|
667
|
+
try { await fsP.access(target); throw new Error(`skill '${shortName}' already installed`) }
|
|
668
|
+
catch (e) { if (e.code !== 'ENOENT') throw e }
|
|
669
|
+
} else { await fsP.rm(target, { recursive: true, force: true }) }
|
|
670
|
+
await copyDir(sourceDir, target)
|
|
671
|
+
invalidate()
|
|
672
|
+
return { name: shortName, path: target }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function installMarketSkill(fullName, overwrite) {
|
|
676
|
+
let sourceDir
|
|
677
|
+
for (const root of marketDirs()) {
|
|
678
|
+
try { sourceDir = await resolveSkillDir(root, fullName); break }
|
|
679
|
+
catch (e) { if (!String(e && e.message).includes('not found')) throw e }
|
|
680
|
+
}
|
|
681
|
+
if (sourceDir === undefined) throw new Error(`skill '${fullName}' not found in market`)
|
|
682
|
+
return copyIntoLibrary(sourceDir, installDirName(fullName), overwrite)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function installFromExecutor(executorKey, fullName, overwrite) {
|
|
686
|
+
const row = findExecutorRow(executorKey)
|
|
687
|
+
if (row === undefined) throw new Error(`unknown executor '${executorKey}'`)
|
|
688
|
+
const sourceDir = await findDirUnderRoot(row.root, fullName, `${row.label} (${row.key})`)
|
|
689
|
+
return copyIntoLibrary(sourceDir, installDirName(fullName), overwrite)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function deleteSkill(name, executorKey) {
|
|
693
|
+
if (!validSkillName(name)) throw new Error('invalid skill name')
|
|
694
|
+
const key = executorKey === undefined || executorKey === null || executorKey === '' ? 'dsh' : executorKey
|
|
695
|
+
const row = findExecutorRow(key)
|
|
696
|
+
if (row === undefined) throw new Error(`unknown executor '${key}'`)
|
|
697
|
+
if (row.readOnly) throw new Error(`source '${key}' is read-only; cannot delete skills there`)
|
|
698
|
+
const target = join(row.root, name)
|
|
699
|
+
const stat = await fsP.stat(target).catch(() => undefined)
|
|
700
|
+
if (stat === undefined || !stat.isDirectory()) throw new Error(`skill '${name}' not found in ${row.label} (${row.key})`)
|
|
701
|
+
await fsP.rm(target, { recursive: true })
|
|
702
|
+
if (key === 'dsh') invalidate()
|
|
703
|
+
return { removed: name, executor: key }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── Market sync state (persisted next to the repo root) ──
|
|
707
|
+
const marketStateFile = join(resolve(installedDir, '..'), 'skills-market-sync.json')
|
|
708
|
+
let marketState = { lastSyncAt: undefined, lastResult: undefined }
|
|
709
|
+
// User-facing settings live in the host settings service when present;
|
|
710
|
+
// the local json only carries runtime sync bookkeeping.
|
|
711
|
+
let settingsScope = null
|
|
712
|
+
const settingsOverrides = {} // fallback sheet when the service is absent
|
|
713
|
+
const marketStateLoaded = fsP.readFile(marketStateFile, 'utf8')
|
|
714
|
+
.then(raw => {
|
|
715
|
+
const parsed = JSON.parse(raw)
|
|
716
|
+
marketState = { lastSyncAt: parsed.lastSyncAt, lastResult: parsed.lastResult }
|
|
717
|
+
// one-time migration: pre-settings-service overrides move into the
|
|
718
|
+
// settings namespace, then are blanked in the legacy file
|
|
719
|
+
if (parsed.settings && typeof parsed.settings === 'object' && Object.keys(parsed.settings).length > 0) {
|
|
720
|
+
const legacy = parsed.settings
|
|
721
|
+
Promise.resolve().then(async () => {
|
|
722
|
+
await marketStateLoaded
|
|
723
|
+
if (settingsScope && typeof settingsScope.update === 'function') {
|
|
724
|
+
try {
|
|
725
|
+
await settingsScope.update(legacy)
|
|
726
|
+
await fsP.writeFile(marketStateFile, JSON.stringify(marketState, null, 2), { mode: 0o600 })
|
|
727
|
+
} catch (e) { ctx.logger.warn(`skills-management: legacy settings migration: ${e && e.message}`) }
|
|
728
|
+
} else {
|
|
729
|
+
Object.assign(settingsOverrides, legacy)
|
|
730
|
+
}
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
.catch(() => {})
|
|
735
|
+
const baseSettings = () => {
|
|
736
|
+
const cfg = (config.marketSync && typeof config.marketSync === 'object') ? config.marketSync : {}
|
|
737
|
+
const base = { ...DEFAULT_MARKET_SYNC }
|
|
738
|
+
for (const key of ['url', 'branch', 'gitBinary', 'autoSync', 'syncOnStartup']) {
|
|
739
|
+
if (cfg[key] !== undefined) base[key] = cfg[key]
|
|
740
|
+
}
|
|
741
|
+
if (config.marketRepoDir !== undefined) base.repoDir = resolve(expandTilde(config.marketRepoDir))
|
|
742
|
+
return base
|
|
743
|
+
}
|
|
744
|
+
// settings 注册:静态 inject 已保证 ctx.settings 就绪(此前走动态 ctx.inject 且
|
|
745
|
+
// schema 用 zod——不兼容导致 register 静默失败,token 只能存内存、重启即失)
|
|
746
|
+
if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
|
|
747
|
+
try {
|
|
748
|
+
settingsScope = ctx.settings.register(MARKET_SETTINGS_NS, marketSettingsSchema(), { base: baseSettings() })
|
|
749
|
+
} catch (e) { ctx.logger.warn(`skills-management: settings register: ${e && e.message}`) }
|
|
750
|
+
}
|
|
751
|
+
const saveMarketState = async () => {
|
|
752
|
+
// 0600: the state file may carry the access token
|
|
753
|
+
try { await fsP.writeFile(marketStateFile, JSON.stringify(marketState, null, 2), { mode: 0o600 }) } catch {}
|
|
754
|
+
try { await fsP.chmod(marketStateFile, 0o600) } catch {}
|
|
755
|
+
}
|
|
756
|
+
const marketSettings = () => {
|
|
757
|
+
if (settingsScope && typeof settingsScope.get === 'function') {
|
|
758
|
+
const v = settingsScope.get()
|
|
759
|
+
if (v && typeof v === 'object') return { ...baseSettings(), ...v }
|
|
760
|
+
}
|
|
761
|
+
return mergeMarketSync(config, settingsOverrides)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let marketSyncRun = null
|
|
765
|
+
const runMarketSync = async () => {
|
|
766
|
+
if (marketSyncRun !== null) return marketSyncRun
|
|
767
|
+
marketSyncRun = (async () => {
|
|
768
|
+
await marketStateLoaded
|
|
769
|
+
const eff = marketSettings()
|
|
770
|
+
const ok = await gitAvailable(eff.gitBinary)
|
|
771
|
+
if (!ok) throw new Error('git is not available on PATH')
|
|
772
|
+
const started = Date.now()
|
|
773
|
+
const repoDir = effectiveRepoDir()
|
|
774
|
+
const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token)
|
|
775
|
+
marketState.lastSyncAt = new Date().toISOString()
|
|
776
|
+
marketState.lastResult = { ...result, at: marketState.lastSyncAt, durationMs: Date.now() - started }
|
|
777
|
+
await saveMarketState()
|
|
778
|
+
invalidate()
|
|
779
|
+
return { ...marketState.lastResult, url: eff.url, branch: eff.branch, dir: repoDir }
|
|
780
|
+
})().finally(() => { marketSyncRun = null })
|
|
781
|
+
return marketSyncRun
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Startup + periodic auto-sync (fire-and-forget; failures only warn)
|
|
785
|
+
ctx.effect(() => {
|
|
786
|
+
const eff = marketSettings()
|
|
787
|
+
if (eff.syncOnStartup) {
|
|
788
|
+
marketStateLoaded.then(() => runMarketSync()).catch(e => ctx.logger.warn(`skills-management: startup market sync: ${e && e.message}`))
|
|
789
|
+
}
|
|
790
|
+
const timer = setInterval(() => {
|
|
791
|
+
const now = Date.now()
|
|
792
|
+
const eff2 = marketSettings()
|
|
793
|
+
if (!eff2.autoSync) return
|
|
794
|
+
const last = marketState.lastSyncAt ? Date.parse(marketState.lastSyncAt) : 0
|
|
795
|
+
if (now - last > 24 * 3600 * 1000) {
|
|
796
|
+
runMarketSync().catch(e => ctx.logger.warn(`skills-management: auto market sync: ${e && e.message}`))
|
|
797
|
+
}
|
|
798
|
+
}, 6 * 3600 * 1000)
|
|
799
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
800
|
+
return () => clearInterval(timer)
|
|
801
|
+
}, 'skills-management: market auto-sync')
|
|
802
|
+
|
|
803
|
+
const shareRunJobs = new Map()
|
|
804
|
+
// Same-process Agent services (the web app's own): when available the
|
|
805
|
+
// share run streams live; absent compositions fall back to headless spawn.
|
|
806
|
+
let shareServices = null
|
|
807
|
+
try {
|
|
808
|
+
if (ctx.inject && typeof ctx.inject === 'function') {
|
|
809
|
+
ctx.inject(['agents', 'agentDefaultModel', 'sessions'], (svcs) => { shareServices = svcs })
|
|
810
|
+
}
|
|
811
|
+
} catch {}
|
|
812
|
+
let providerControl
|
|
813
|
+
const invalidate = () => { if (providerControl !== undefined) providerControl.invalidate() }
|
|
814
|
+
|
|
815
|
+
ctx.skills.registerProvider((control) => {
|
|
816
|
+
providerControl = control
|
|
817
|
+
control.signal.addEventListener('abort', () => { if (providerControl === control) providerControl = undefined }, { once: true })
|
|
818
|
+
return {
|
|
819
|
+
name: providerName,
|
|
820
|
+
async list() {
|
|
821
|
+
const { market, installed } = await discoverAll()
|
|
822
|
+
const candidates = []
|
|
823
|
+
// Fail-soft: the registry throws — and kills the requesting session's
|
|
824
|
+
// turn — on candidates that fail harness validation (empty
|
|
825
|
+
// description, non-kebab-case name). Market checkouts with malformed
|
|
826
|
+
// frontmatter trigger both routinely, so pre-filter here.
|
|
827
|
+
const isValid = (row) => {
|
|
828
|
+
if (!row.name || !KEBAB_NAME_RE.test(row.name)) return `invalid name '${row.name}'`
|
|
829
|
+
if (!row.description || row.description.trim() === '') return 'empty description'
|
|
830
|
+
return undefined
|
|
831
|
+
}
|
|
832
|
+
for (const row of installed) {
|
|
833
|
+
const why = isValid(row)
|
|
834
|
+
if (why !== undefined) {
|
|
835
|
+
ctx.logger.warn(`skills-management: skipping installed skill '${row.name}' (${row.entry.dir}): ${why}`)
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
candidates.push(toCandidate(row, 'user-installed', RANK_INSTALLED))
|
|
839
|
+
}
|
|
840
|
+
for (const row of market) {
|
|
841
|
+
const why = isValid(row)
|
|
842
|
+
if (why !== undefined) {
|
|
843
|
+
ctx.logger.warn(`skills-management: skipping market skill '${row.entry.relPath}': ${why}`)
|
|
844
|
+
continue
|
|
845
|
+
}
|
|
846
|
+
const shortName = row.name.includes('/') ? row.name.split('/').pop() : row.name
|
|
847
|
+
if (installed.some((e) => e.name === shortName)) continue
|
|
848
|
+
candidates.push(toCandidate(row, 'market', RANK_MARKET, { modelInvocable: marketModelInvocable }))
|
|
849
|
+
}
|
|
850
|
+
return candidates
|
|
851
|
+
},
|
|
852
|
+
async get(candidate) {
|
|
853
|
+
const entry = candidate.locator
|
|
854
|
+
try {
|
|
855
|
+
const row = await readSkillEntry({ ...entry, stat: entry.stat ?? (await fsP.stat(join(entry.dir, 'SKILL.md'))) })
|
|
856
|
+
const invocation = candidate.source === 'market' && !marketModelInvocable
|
|
857
|
+
? { modelInvocable: false, userInvocable: true }
|
|
858
|
+
: invocationPolicy(row.meta)
|
|
859
|
+
return { name: row.name, description: row.description, whenToUse: typeof row.meta.whenToUse === 'string' ? row.meta.whenToUse : undefined, invocation, source: candidate.source, provider: providerName, resourceBase: { kind: 'directory', path: entry.dir }, content: row.body, path: join(entry.dir, 'SKILL.md'), metadata: row.meta }
|
|
860
|
+
} catch { return undefined }
|
|
861
|
+
},
|
|
862
|
+
}
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
// `invocationOverride.modelInvocable` 为 false 时该候选不进模型目录(available_skills),
|
|
866
|
+
// 但保留 userInvocable(UI 浏览 / 用户命令调用不受影响)。
|
|
867
|
+
function toCandidate(row, source, rank, invocationOverride) {
|
|
868
|
+
const base = invocationPolicy(row.meta)
|
|
869
|
+
const invocation = invocationOverride ? { ...base, ...invocationOverride } : base
|
|
870
|
+
return { name: row.name, description: row.description, invocation, source, provider: providerName, rank, locator: { dir: row.entry.dir, root: row.entry.root, relPath: row.entry.relPath, stat: row.entry.stat }, path: join(row.entry.dir, 'SKILL.md'), metadata: row.meta, whenToUse: typeof row.meta.whenToUse === 'string' ? row.meta.whenToUse : undefined, resourceBase: { kind: 'directory', path: row.entry.dir } }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
ctx.effect(() => ctx.webServer.register({
|
|
874
|
+
kind: 'prefix',
|
|
875
|
+
path: '/skills-management/api',
|
|
876
|
+
handler: async (req, res) => {
|
|
877
|
+
try {
|
|
878
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
879
|
+
const apiPath = url.pathname.replace(/\/+$/, '')
|
|
880
|
+
const query = url.searchParams
|
|
881
|
+
|
|
882
|
+
// GET /skills-management/api/market/status
|
|
883
|
+
if (req.method === 'GET' && apiPath.endsWith('/skills-management/api/market/status')) {
|
|
884
|
+
await marketStateLoaded
|
|
885
|
+
const eff = marketSettings()
|
|
886
|
+
const repoDir = effectiveRepoDir()
|
|
887
|
+
const repoExists = await fsP.access(join(repoDir, '.git')).then(() => true).catch(() => false)
|
|
888
|
+
const ok = await gitAvailable(eff.gitBinary)
|
|
889
|
+
const [localCommit, remoteCommit] = repoExists && ok
|
|
890
|
+
? [await gitCurrentCommit(eff.gitBinary, repoDir), await gitRemoteCommit(eff.gitBinary, repoDir, 'origin', eff.branch)]
|
|
891
|
+
: [undefined, undefined]
|
|
892
|
+
sendJson(res, 200, {
|
|
893
|
+
url: eff.url, branch: eff.branch, dir: displayPath(repoDir),
|
|
894
|
+
gitAvailable: ok, repoExists,
|
|
895
|
+
localCommit, remoteCommit,
|
|
896
|
+
needsUpdate: localCommit !== undefined && remoteCommit !== undefined ? localCommit !== remoteCommit : undefined,
|
|
897
|
+
lastSyncAt: marketState.lastSyncAt, lastResult: marketState.lastResult,
|
|
898
|
+
autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
|
|
899
|
+
hasToken: typeof eff.token === 'string' && eff.token !== '',
|
|
900
|
+
syncing: marketSyncRun !== null,
|
|
901
|
+
})
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// POST /skills-management/api/market/sync
|
|
906
|
+
if (req.method === 'POST' && apiPath.endsWith('/skills-management/api/market/sync')) {
|
|
907
|
+
try {
|
|
908
|
+
const result = await runMarketSync()
|
|
909
|
+
sendJson(res, 200, result)
|
|
910
|
+
} catch (e) { sendJson(res, 400, { error: String(e && e.message || e) }) }
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// PUT /skills-management/api/market/settings {url?, branch?, autoSync?, syncOnStartup?}
|
|
915
|
+
if (req.method === 'PUT' && apiPath.endsWith('/skills-management/api/market/settings')) {
|
|
916
|
+
// body first: readJsonBody attaches listeners synchronously, so no
|
|
917
|
+
// event can slip past while the state-file promise resolves
|
|
918
|
+
const body = await readJsonBody(req)
|
|
919
|
+
await marketStateLoaded
|
|
920
|
+
const patch = {}
|
|
921
|
+
for (const key of ['url', 'branch', 'gitBinary']) {
|
|
922
|
+
if (typeof body[key] === 'string' && body[key] !== '') patch[key] = body[key]
|
|
923
|
+
}
|
|
924
|
+
// token: non-empty string sets it; null or '' clears it. Never echoed.
|
|
925
|
+
if (typeof body.token === 'string' && body.token !== '') patch.token = body.token
|
|
926
|
+
if (body.token === null || body.token === '') patch.token = undefined
|
|
927
|
+
if (typeof body.repoDir === 'string' && body.repoDir !== '') {
|
|
928
|
+
patch.repoDir = resolve(expandTilde(body.repoDir))
|
|
929
|
+
}
|
|
930
|
+
for (const key of ['autoSync', 'syncOnStartup']) {
|
|
931
|
+
if (typeof body[key] === 'boolean') patch[key] = body[key]
|
|
932
|
+
}
|
|
933
|
+
if (settingsScope && typeof settingsScope.update === 'function') {
|
|
934
|
+
await settingsScope.update(patch)
|
|
935
|
+
} else {
|
|
936
|
+
Object.assign(settingsOverrides, patch)
|
|
937
|
+
}
|
|
938
|
+
const eff = marketSettings()
|
|
939
|
+
const { token, ...safe } = eff // token 只写不回读
|
|
940
|
+
sendJson(res, 200, { settings: safe, hasToken: typeof token === 'string' && token !== '' })
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// POST /skills-management/api/share/run {prompt, dir} → real headless run
|
|
945
|
+
if (req.method === 'POST' && apiPath.endsWith('/skills-management/api/share/run')) {
|
|
946
|
+
const body = await readJsonBody(req)
|
|
947
|
+
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') { sendJson(res, 400, { error: 'body must provide prompt' }); return }
|
|
948
|
+
if (typeof body.dir !== 'string' || body.dir === '') { sendJson(res, 400, { error: 'body must provide dir' }); return }
|
|
949
|
+
const dir = resolve(expandTilde(body.dir))
|
|
950
|
+
const stat = await fsP.stat(dir).catch(() => undefined)
|
|
951
|
+
if (stat === undefined || !stat.isDirectory()) { sendJson(res, 400, { error: `dir not found: ${displayPath(dir)}` }); return }
|
|
952
|
+
const binary = process.env.SKILLS_DSH_BIN || 'dsh'
|
|
953
|
+
const job = createShareRunJob({ binary, prompt: body.prompt, dir, jobs: shareRunJobs, logger: ctx.logger, services: shareServices })
|
|
954
|
+
sendJson(res, 202, { jobId: job.id, status: job.status })
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// GET /skills-management/api/share/run?id= → job status/output
|
|
959
|
+
if (req.method === 'GET' && apiPath.endsWith('/skills-management/api/share/run')) {
|
|
960
|
+
const id = query.get('id') || ''
|
|
961
|
+
const job = shareRunJobs.get(id)
|
|
962
|
+
if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
|
|
963
|
+
sendJson(res, 200, { ...job, output: job.output.slice(-32 * 1024) })
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// GET /skills-management/api/executors → on-machine sources.
|
|
968
|
+
// Variants: ?mode=summary (counts only, no skill arrays) and
|
|
969
|
+
// ?executor=<key> (one source, full list — lazy drill-in).
|
|
970
|
+
if (req.method === 'GET' && apiPath.endsWith('/skills-management/api/executors')) {
|
|
971
|
+
const scopeKey = query.get('executor')
|
|
972
|
+
if (scopeKey !== null && scopeKey !== '') {
|
|
973
|
+
const scoped = findExecutorRow(scopeKey)
|
|
974
|
+
if (scoped === undefined) throw new Error(`unknown executor '${scopeKey}'`)
|
|
975
|
+
sendJson(res, 200, { executor: await scanExecutor(scoped) })
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
const countsOnly = query.get('mode') === 'summary'
|
|
979
|
+
const executors = []
|
|
980
|
+
for (const row of executorRows) executors.push(await scanExecutor(row, countsOnly))
|
|
981
|
+
sendJson(res, 200, { executors })
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// GET /skills-management/api → list
|
|
986
|
+
if (req.method === 'GET' && apiPath === '/skills-management/api') {
|
|
987
|
+
const { market, installed } = await discoverAll()
|
|
988
|
+
const sources = new Map()
|
|
989
|
+
for (const row of market) {
|
|
990
|
+
const sourceKey = row.entry.relPath.split('/')[0]
|
|
991
|
+
const agg = sources.get(sourceKey) ?? { source: sourceKey, skills: 0, displayName: sourceKey }
|
|
992
|
+
agg.skills += 1
|
|
993
|
+
sources.set(sourceKey, agg)
|
|
994
|
+
}
|
|
995
|
+
const installedNames = new Set(installed.map((r) => r.name))
|
|
996
|
+
sendJson(res, 200, {
|
|
997
|
+
sources: [...sources.values()],
|
|
998
|
+
market: market.map((row) => ({ name: row.entry.relPath, shortName: row.name, source: row.entry.relPath.split('/')[0], description: truncateDescription(row.description), keywords: row.keywords, version: row.version, installed: installedNames.has(row.name), totalSize: 0 })),
|
|
999
|
+
installed: await Promise.all(installed.map(async (row) => { const { fileCount, totalSize } = await countFilesAndSize(row.entry.dir); return { name: row.name, description: truncateDescription(row.description), path: row.entry.dir, fileCount, totalSize, modifiedAt: row.modifiedAt } })),
|
|
1000
|
+
})
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// GET /skills-management/api/detail?name=&executor= → detail
|
|
1005
|
+
if (req.method === 'GET' && apiPath.endsWith('/skills-management/api/detail')) {
|
|
1006
|
+
const name = query.get('name') || ''
|
|
1007
|
+
const located = await locateNamedSkillDir(name, query.get('executor'))
|
|
1008
|
+
const content = await fsP.readFile(join(located.dir, 'SKILL.md'), 'utf8')
|
|
1009
|
+
const files = await walkFiles(located.dir, located.dir)
|
|
1010
|
+
const { fileCount, totalSize } = await countFilesAndSize(located.dir)
|
|
1011
|
+
const { meta, body } = parseSkillMd(content)
|
|
1012
|
+
sendJson(res, 200, { name, shortName: basename(name), dir: displayPath(located.dir), executor: located.executorKey, isInstalled: located.isInstalled, content: body, contentWithMeta: content, meta, files, fileCount, totalSize, modifiedAt: files[0]?.modifiedAt })
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// GET /skills-management/api/file?name=&path=&executor= → file content
|
|
1017
|
+
if (req.method === 'GET' && apiPath.endsWith('/skills-management/api/file')) {
|
|
1018
|
+
const name = query.get('name') || '', filePath = query.get('path') || ''
|
|
1019
|
+
const located = await locateNamedSkillDir(name, query.get('executor'))
|
|
1020
|
+
await sendSkillFile(res, located.dir, filePath, contentTypeFor(filePath))
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// POST /skills-management/api/install {name, from?, overwrite?}
|
|
1025
|
+
if (req.method === 'POST' && apiPath.endsWith('/skills-management/api/install')) {
|
|
1026
|
+
const body = await readJsonBody(req)
|
|
1027
|
+
if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
|
|
1028
|
+
const result = typeof body.from === 'string' && body.from !== '' && body.from !== 'market'
|
|
1029
|
+
? await installFromExecutor(body.from, body.name, body.overwrite === true)
|
|
1030
|
+
: await installMarketSkill(body.name, body.overwrite === true)
|
|
1031
|
+
sendJson(res, 201, { installed: { ...result, from: typeof body.from === 'string' && body.from !== '' && body.from !== 'market' ? body.from : 'market' } })
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// DELETE /skills-management/api {name, executor?} → remove
|
|
1036
|
+
if (req.method === 'DELETE' && apiPath.endsWith('/skills-management/api')) {
|
|
1037
|
+
const body = await readJsonBody(req)
|
|
1038
|
+
if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
|
|
1039
|
+
sendJson(res, 200, await deleteSkill(body.name, typeof body.executor === 'string' ? body.executor : undefined))
|
|
1040
|
+
return
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// PUT /skills-management/api/invocation {name, modelInvocable} → 治理键开关
|
|
1044
|
+
// dsh 原生 frontmatter 键(docs/subsystems/skills.md):disable-model-invocation。
|
|
1045
|
+
// 解析范围:用户库(dsh)优先,找不到再查 ~/.agents/skills——dsh 的
|
|
1046
|
+
// skill-filesystem 把 user-agents 作为内置根全量扫进模型目录,这个开关
|
|
1047
|
+
// 同样管得住它们(其他键原样保留;写完靠宿主 watcher 失效,无需 invalidate)。
|
|
1048
|
+
if (req.method === 'PUT' && apiPath.endsWith('/skills-management/api/invocation')) {
|
|
1049
|
+
const body = await readJsonBody(req)
|
|
1050
|
+
if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
|
|
1051
|
+
if (typeof body.modelInvocable !== 'boolean') { sendJson(res, 400, { error: 'body must provide modelInvocable boolean' }); return }
|
|
1052
|
+
// 与 dsh skill-filesystem 的 user-agents 根同款解析
|
|
1053
|
+
const agentsSkillsRoot = process.env.DSH_AGENTS_HOME !== undefined && process.env.DSH_AGENTS_HOME !== ''
|
|
1054
|
+
? join(resolve(expandTilde(process.env.DSH_AGENTS_HOME)), 'skills')
|
|
1055
|
+
: join(homedir(), '.agents', 'skills')
|
|
1056
|
+
let skillDir
|
|
1057
|
+
let rootKey = 'dsh'
|
|
1058
|
+
try { skillDir = await resolveSkillDir(installedDir, body.name) }
|
|
1059
|
+
catch { skillDir = await resolveSkillDir(agentsSkillsRoot, body.name); rootKey = 'agents' }
|
|
1060
|
+
const file = join(skillDir, 'SKILL.md')
|
|
1061
|
+
const updated = setModelInvocable(await fsP.readFile(file, 'utf8'), body.modelInvocable)
|
|
1062
|
+
await atomicWriteJs(file, updated)
|
|
1063
|
+
invalidate()
|
|
1064
|
+
sendJson(res, 200, { name: body.name, modelInvocable: body.modelInvocable, root: rootKey })
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
sendJson(res, 404, { error: 'not found' })
|
|
1069
|
+
} catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
|
|
1070
|
+
},
|
|
1071
|
+
}), 'skills-management: api route')
|
|
1072
|
+
},
|
|
1073
|
+
}
|