@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/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
+ }