@weibaohui/experts-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,949 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * dsh-plugin-experts-management — Host half
5
+ *
6
+ * Manages ntd-format experts (WorkBuddy plugin.json + Agent MD + skills)
7
+ * WITHOUT touching the ntd application's own directories:
8
+ * - Market: ntd-resource's experts/ subtree via git sparse checkout into the
9
+ * plugin's own dir ($DSH_HOME/experts-management/market). Read-only shelf;
10
+ * install copies into the user library.
11
+ * - User library: $DSH_HOME/experts (writable, the only built-in source).
12
+ * Additional directories are opt-in via config.extraSources.
13
+ * - Model integration: every expert registers on the host skills registry as
14
+ * a USER-INVOCABLE, MODEL-INVISIBLE skill (`disable-model-invocation`
15
+ * semantics, name `expert-<name>`). Typing `/expert-<name>` in a message
16
+ * (or picking it from the composer menu) makes the host's user-explicit
17
+ * gesture boundary deterministically inject the expert's role prompt —
18
+ * ntd's three-section injection, zero model-catalog tokens, zero host code.
19
+ */
20
+
21
+ const { createReadStream } = require('node:fs')
22
+ const { execFile } = require('node:child_process')
23
+ const { randomUUID } = require('node:crypto')
24
+ const fsP = require('node:fs/promises')
25
+ const { basename, join, relative, resolve, sep } = require('node:path')
26
+ const { homedir } = require('node:os')
27
+ const YAML = require('yaml')
28
+ // settings 服务要求 schemastery schema(可调用 + toJSON;zod 不兼容,register 会抛错被吞)。
29
+ // 宿主沙箱内解析打包依赖可能抛 ERR_INTERNAL_ASSERTION(.pnpm 软链),因此优先沿
30
+ // dsh 全局安装取 settings 服务自用的那份副本,本地开发/测试再退回标准 require。
31
+ function loadSchemastery() {
32
+ const errors = []
33
+ const { createRequire } = require('node:module')
34
+ for (const prefix of [process.env.DSH_GLOBAL_PREFIX, join(homedir(), '.local')].filter(Boolean)) {
35
+ const hostCopy = join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery', 'lib', 'index.cjs')
36
+ try { return createRequire(hostCopy)(hostCopy) } catch (e) { errors.push(String(e && e.code || e)) }
37
+ }
38
+ try { return require('@deepseek-ai/schemastery') } catch (e) { errors.push(String(e && e.code || e)) }
39
+ if (process.env.EXPERTS_SETTINGS_DEBUG) console.warn(`[experts-management] schemastery unavailable: ${errors.join(' | ')}`)
40
+ return null
41
+ }
42
+ const Schema = loadSchemastery()
43
+
44
+ /** dsh 数据根(与宿主一致:$DSH_HOME,缺省 ~/.dsh)。 */
45
+ function dshHome() {
46
+ return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh')
47
+ }
48
+
49
+ const MARKET_SCAN_SKIP = new Set(['.git', 'node_modules'])
50
+ const RANK_INSTALLED = 100
51
+ const RANK_MARKET = 500
52
+ const MAX_BODY_BYTES = 64 * 1024
53
+ const DESCRIPTION_LIMIT = 140
54
+ // 同款正则见 skill/skill/src/index.ts SKILL_NAME —— 不合规的候选会让 registry 抛错
55
+ const KEBAB_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
56
+ // 专家在宿主技能注册表里的名字前缀:expert-<plugin.name>,防与真实技能撞名
57
+ const EXPERT_NAME_PREFIX = 'expert-'
58
+
59
+ /**
60
+ * Built-in sources: ONLY the dsh user library. ntd 应用自身的目录
61
+ * (~/.ntd/*)一律不扫描不读取;需要纳管其他目录时经 config.extraSources
62
+ * 显式加入(可标 readOnly)。
63
+ */
64
+ const SOURCE_DEFS = [
65
+ { key: 'dsh', label: 'DSH' },
66
+ ]
67
+
68
+ /** plugin.json 的固定入口目录(WorkBuddy/CodeBuddy 兼容格式)。 */
69
+ const PLUGIN_JSON_REL = '.codebuddy-plugin/plugin.json'
70
+
71
+ /** Absolute path with the $HOME prefix folded to `~` (no username leaks in UI). */
72
+ function displayPath(p) {
73
+ const home = homedir()
74
+ if (p === home) return '~'
75
+ if (p.startsWith(home + sep)) return '~' + p.slice(home.length)
76
+ return p
77
+ }
78
+
79
+ // ── Parsing: frontmatter + plugin.json ───────────────────────────────────
80
+
81
+ function extractFrontmatter(content) {
82
+ const lines = content.split(/\r?\n/)
83
+ if (lines[0] === undefined || lines[0].trim() !== '---') return undefined
84
+ const yamlLines = []
85
+ for (let index = 1; index < lines.length; index += 1) {
86
+ const line = lines[index]
87
+ if (line.trim() === '---') return yamlLines.join('\n')
88
+ yamlLines.push(line)
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ function parseFrontmatter(content) {
94
+ const yamlText = extractFrontmatter(content)
95
+ if (yamlText === undefined) return { meta: {}, body: content }
96
+ let meta = {}
97
+ try {
98
+ const parsed = YAML.parse(yamlText)
99
+ if (parsed !== null && typeof parsed === 'object') meta = parsed
100
+ } catch {}
101
+ const lines = content.split(/\r?\n/)
102
+ let closer = -1
103
+ for (let index = 1; index < lines.length; index += 1) {
104
+ if (lines[index].trim() === '---') { closer = index; break }
105
+ }
106
+ // body 切掉 frontmatter 块(含紧随的空行):注入 prompt 只携带正文
107
+ const body = closer >= 0 ? lines.slice(closer + 1).join('\n').replace(/^\r?\n/, '') : content
108
+ return { meta, body }
109
+ }
110
+
111
+ /** 取 LocalizedText({zh,en} 或纯字符串)某一语言,带回退。 */
112
+ function localized(value, lang, fallbackLang) {
113
+ if (typeof value === 'string') return value
114
+ if (value !== null && typeof value === 'object') {
115
+ const primary = typeof value[lang] === 'string' ? value[lang] : undefined
116
+ const secondary = fallbackLang !== undefined && typeof value[fallbackLang] === 'string' ? value[fallbackLang] : undefined
117
+ return primary ?? secondary
118
+ }
119
+ return undefined
120
+ }
121
+
122
+ function truncateDescription(text) {
123
+ if (typeof text !== 'string') return ''
124
+ const single = text.split(/\r?\n/)[0].trim()
125
+ return single.length > DESCRIPTION_LIMIT ? single.slice(0, DESCRIPTION_LIMIT) + '…' : single
126
+ }
127
+
128
+ /** 解析 agents/<name>.md 的 YAML frontmatter(name/description/color/emoji/vibe)。 */
129
+ function parseAgentMd(content, fallbackName) {
130
+ const { meta } = parseFrontmatter(content)
131
+ return {
132
+ name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : fallbackName,
133
+ description: typeof meta.description === 'string' ? meta.description : undefined,
134
+ color: typeof meta.color === 'string' ? meta.color : undefined,
135
+ emoji: typeof meta.emoji === 'string' ? meta.emoji : undefined,
136
+ vibe: typeof meta.vibe === 'string' ? meta.vibe : undefined,
137
+ }
138
+ }
139
+
140
+ /** 解析 skills/<name>/SKILL.md 的 frontmatter 摘要。 */
141
+ function parseSkillMd(content) {
142
+ const { meta } = parseFrontmatter(content)
143
+ return {
144
+ name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : undefined,
145
+ description: typeof meta.description === 'string' ? meta.description : undefined,
146
+ descriptionZh: typeof meta.description_zh === 'string' ? meta.description_zh : undefined,
147
+ descriptionEn: typeof meta.description_en === 'string' ? meta.description_en : undefined,
148
+ version: typeof meta.version === 'string' ? meta.version : undefined,
149
+ emoji: typeof meta.emoji === 'string' ? meta.emoji : undefined,
150
+ }
151
+ }
152
+
153
+ /**
154
+ * 解析 plugin.json → 专家记录的“头部”字段(不含 agent/skill 明细)。
155
+ * 展示字段按 ntd 语义回退:displayName.zh → .en → name。
156
+ */
157
+ function parsePluginJson(raw) {
158
+ const plugin = typeof raw === 'string' ? JSON.parse(raw) : raw
159
+ const name = String(plugin.name || '')
160
+ const expertType = plugin.expertType === 'team' ? 'team' : 'agent'
161
+ const members = Array.isArray(plugin.members) ? plugin.members.map((m) => ({
162
+ id: String(m.id || ''),
163
+ nameZh: localized(m.name, 'zh', 'en'),
164
+ nameEn: localized(m.name, 'en'),
165
+ professionZh: localized(m.profession, 'zh', 'en'),
166
+ professionEn: localized(m.profession, 'en'),
167
+ avatar: typeof m.avatar === 'string' ? m.avatar : undefined,
168
+ role: m.role === 'lead' ? 'lead' : 'member',
169
+ })) : []
170
+ const teamInfo = plugin.teamInfo !== null && typeof plugin.teamInfo === 'object' ? plugin.teamInfo : {}
171
+ return {
172
+ name,
173
+ expertType,
174
+ version: typeof plugin.version === 'string' ? plugin.version : undefined,
175
+ displayNameZh: localized(plugin.displayName, 'zh', 'en') ?? (name || undefined),
176
+ displayNameEn: localized(plugin.displayName, 'en') ?? (name || undefined),
177
+ professionZh: localized(plugin.profession, 'zh', 'en'),
178
+ professionEn: localized(plugin.profession, 'en'),
179
+ descZh: localized(plugin.displayDescription, 'zh', 'en') ?? (typeof plugin.description_zh === 'string' ? plugin.description_zh : undefined) ?? (typeof plugin.description === 'string' ? plugin.description : undefined),
180
+ descEn: localized(plugin.displayDescription, 'en') ?? (typeof plugin.description === 'string' ? plugin.description : undefined),
181
+ avatar: typeof plugin.avatar === 'string' && plugin.avatar !== '' ? plugin.avatar : undefined,
182
+ categoryId: typeof plugin.categoryId === 'string' ? plugin.categoryId : undefined,
183
+ agents: Array.isArray(plugin.agents) ? plugin.agents.map(String) : undefined,
184
+ agentName: typeof plugin.agentName === 'string' && plugin.agentName !== '' ? plugin.agentName : undefined,
185
+ leadAgent: typeof teamInfo.leadAgent === 'string' && teamInfo.leadAgent !== '' ? teamInfo.leadAgent : undefined,
186
+ memberAgents: Array.isArray(teamInfo.memberAgents) ? teamInfo.memberAgents.map(String) : [],
187
+ members,
188
+ skills: Array.isArray(plugin.skills) ? plugin.skills.map(String) : [],
189
+ defaultInitPromptZh: localized(plugin.defaultInitPrompt, 'zh', 'en'),
190
+ defaultInitPromptEn: localized(plugin.defaultInitPrompt, 'en'),
191
+ quickPromptsZh: Array.isArray(plugin.quickPrompts) ? plugin.quickPrompts.map((q) => localized(q, 'zh', 'en')).filter(Boolean) : [],
192
+ tags: Array.isArray(plugin.tags) ? plugin.tags.map((t) => ({ zh: localized(t, 'zh', 'en') || '', en: localized(t, 'en') || '' })) : [],
193
+ }
194
+ }
195
+
196
+ // ── Path safety(ntd resolve_within 语义)────────────────────────────────
197
+
198
+ /** 解析相对路径并校验仍位于 base 内,防 plugin.json 里的 .. / 绝对路径越界读文件。 */
199
+ function resolveWithin(base, rel) {
200
+ if (typeof rel !== 'string' || rel === '') return undefined
201
+ const target = resolve(base, rel)
202
+ const baseResolved = resolve(base)
203
+ if (target === baseResolved || target.startsWith(baseResolved + sep)) return target
204
+ return undefined
205
+ }
206
+
207
+ /** ntd is_safe_expert_name:目录名只拒绝路径分隔符、父级引用与控制字符(中文名合法)。 */
208
+ function isSafeExpertName(name) {
209
+ if (typeof name !== 'string' || name === '') return false
210
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) return false
211
+ if ([...name].some((ch) => { const c = ch.codePointAt(0); return c < 32 || c === 127 })) return false
212
+ return true
213
+ }
214
+
215
+ // ── Scanning ─────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * 扫描一个专家来源根目录(ntd 语义:只看一层子目录,每个含
219
+ * .codebuddy-plugin/plugin.json 的目录是一个专家)。
220
+ * 失败的专家跳过并返回 errors,单个坏专家不拖垮整个市场。
221
+ */
222
+ async function scanExpertsRoot(root, sourceKey) {
223
+ const experts = []
224
+ const errors = []
225
+ let entries
226
+ try { entries = await fsP.readdir(root, { withFileTypes: true }) } catch { return { experts, errors } }
227
+ for (const entry of entries) {
228
+ if (!entry.isDirectory()) continue
229
+ if (MARKET_SCAN_SKIP.has(entry.name)) continue
230
+ const dir = join(root, entry.name)
231
+ try {
232
+ experts.push(await readExpertDir(root, dir, sourceKey))
233
+ } catch (e) {
234
+ errors.push(`${entry.name}: ${e && e.message}`)
235
+ }
236
+ }
237
+ experts.sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)
238
+ return { experts, errors }
239
+ }
240
+
241
+ /** 读取单个专家目录 → 完整记录(plugin.json 头部 + agent MD 明细 + skills 明细)。 */
242
+ async function readExpertDir(root, dir, sourceKey) {
243
+ const pluginJsonPath = join(dir, PLUGIN_JSON_REL)
244
+ const raw = await fsP.readFile(pluginJsonPath, 'utf8')
245
+ const head = parsePluginJson(raw)
246
+ if (head.name === '') throw new Error('plugin.json missing name')
247
+ let stat
248
+ try { stat = await fsP.stat(pluginJsonPath) } catch { stat = undefined }
249
+
250
+ // agents 列表:plugin.agents 优先;缺失时扫描 agents/*.md 兜底(旧版 team 格式)
251
+ let agentRels = head.agents
252
+ if (agentRels === undefined) {
253
+ agentRels = []
254
+ try {
255
+ const found = []
256
+ for (const ent of await fsP.readdir(join(dir, 'agents'), { withFileTypes: true })) {
257
+ if (ent.isFile() && ent.name.endsWith('.md')) found.push(`./agents/${ent.name}`)
258
+ }
259
+ agentRels = found.sort()
260
+ } catch { agentRels = [] }
261
+ }
262
+
263
+ const agentFiles = []
264
+ for (const rel of agentRels) {
265
+ const mdPath = resolveWithin(dir, rel)
266
+ if (mdPath === undefined) continue // 路径逃逸:拒绝
267
+ try {
268
+ const content = await fsP.readFile(mdPath, 'utf8')
269
+ const meta = parseAgentMd(content, basename(mdPath).replace(/\.md$/, ''))
270
+ agentFiles.push({ ...meta, relPath: rel, mdPath })
271
+ } catch { /* 单个 agent 文件坏了不影响其余 */ }
272
+ }
273
+
274
+ const skillMeta = []
275
+ for (const rel of head.skills) {
276
+ const skillDir = resolveWithin(dir, rel)
277
+ if (skillDir === undefined) continue
278
+ const skillMdPath = join(skillDir, 'SKILL.md')
279
+ let content
280
+ try { content = await fsP.readFile(skillMdPath, 'utf8') } catch { continue }
281
+ const parsed = parseSkillMd(content)
282
+ skillMeta.push({
283
+ ...parsed,
284
+ skillName: parsed.name ?? basename(skillDir),
285
+ skillDir,
286
+ skillMdPath,
287
+ })
288
+ }
289
+
290
+ return {
291
+ ...head,
292
+ source: sourceKey,
293
+ dir,
294
+ root,
295
+ relPath: relative(root, dir).split(sep).join('/'),
296
+ pluginJsonPath,
297
+ mtime: stat !== undefined ? stat.mtime.toISOString() : undefined,
298
+ agentFiles,
299
+ skillMeta,
300
+ }
301
+ }
302
+
303
+ /** ntd resolve_agent_name:team 用 leadAgent,agent 用 agentName,最后兜底第一个 agent 文件。 */
304
+ function resolveLeadAgentFile(expert) {
305
+ const wanted = expert.leadAgent ?? expert.agentName
306
+ if (expert.agentFiles.length === 0) return undefined
307
+ if (wanted !== undefined) {
308
+ const hit = expert.agentFiles.find((a) => a.name === wanted || a.relPath.endsWith(`/${wanted}.md`) || basename(a.mdPath).replace(/\.md$/, '') === wanted)
309
+ if (hit !== undefined) return hit
310
+ }
311
+ return expert.agentFiles[0]
312
+ }
313
+
314
+ // ── Prompt assembly(ntd 三段式注入的 skill-content 适配)────────────────
315
+
316
+ /** 技能清单段:名称渲染为指向 SKILL.md 的链接,模型按需读完整定义(ntd build_skills_context)。 */
317
+ function buildSkillsContext(skillMeta) {
318
+ if (!Array.isArray(skillMeta) || skillMeta.length === 0) return ''
319
+ const parts = ['## 可用技能', '你可以使用以下技能来辅助完成任务。技能名称是 markdown 链接,指向技能定义文件,如需了解技能详细用法可查看该文件:', '']
320
+ for (const skill of skillMeta) {
321
+ const desc = skill.descriptionZh ?? skill.descriptionEn ?? skill.description ?? '(无描述)'
322
+ parts.push(`- **[${skill.skillName}](${skill.skillMdPath})**: ${desc}`)
323
+ }
324
+ parts.push('', '请根据需要自行调用上述技能。')
325
+ return parts.join('\n')
326
+ }
327
+
328
+ /**
329
+ * 拼专家 prompt:角色定义 → 可用技能(有才出现)→ 身份说明。
330
+ * ntd 在 todo 执行前拼接“# 任务 + 原消息”;这里内容经宿主手势边界作为
331
+ * <skill_content> 注入,用户消息随草稿单独送达,故以一句身份说明收尾。
332
+ */
333
+ function buildExpertPrompt(agentMdBody, skillsText, expert) {
334
+ const displayName = (expert && (expert.displayNameZh ?? expert.displayNameEn)) || ''
335
+ const profession = (expert && (expert.professionZh ?? expert.professionEn)) || ''
336
+ const closing = `\n\n# 身份说明\n你现在是${profession ? `「${profession}」` : ''}专家${displayName ? `「${displayName}」` : ''}。用户的消息中包含具体任务,请严格以上述角色定义的身份、标准与技能完成它。`
337
+ if (skillsText === '') {
338
+ return `# 专家角色定义\n${agentMdBody}${closing}`
339
+ }
340
+ return `# 专家角色定义\n${agentMdBody}\n\n${skillsText}${closing}`
341
+ }
342
+
343
+ // ── Shared route helpers ─────────────────────────────────────────────────
344
+
345
+ function readJsonBody(req) {
346
+ return new Promise((fulfil, reject) => {
347
+ let size = 0, chunks = []
348
+ req.on('data', (chunk) => {
349
+ size += chunk.length
350
+ if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
351
+ chunks.push(chunk)
352
+ })
353
+ req.on('end', () => {
354
+ try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
355
+ catch (error) { reject(new Error(`invalid JSON body: ${error && error.message}`)) }
356
+ })
357
+ req.on('error', reject)
358
+ })
359
+ }
360
+
361
+ function sendJson(res, status, payload) {
362
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
363
+ res.end(JSON.stringify(payload))
364
+ }
365
+
366
+ function contentTypeFor(p) {
367
+ const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase()
368
+ 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' }
369
+ return map[ext]
370
+ }
371
+
372
+ async function sendFile(res, filePath) {
373
+ const stat = await fsP.stat(filePath)
374
+ if (!stat.isFile()) throw new Error('file not found')
375
+ res.writeHead(200, { 'content-type': contentTypeFor(filePath) ?? 'application/octet-stream', 'content-length': stat.size })
376
+ const stream = createReadStream(filePath)
377
+ stream.pipe(res)
378
+ await new Promise((fulfil, reject) => {
379
+ stream.on('error', reject)
380
+ res.on('close', () => fulfil())
381
+ stream.on('end', () => fulfil())
382
+ })
383
+ }
384
+
385
+ async function countFilesAndSize(dir) {
386
+ let fileCount = 0, totalSize = 0
387
+ const walk = async (current) => {
388
+ const entries = await fsP.readdir(current, { withFileTypes: true })
389
+ for (const entry of entries) {
390
+ const entryPath = join(current, entry.name)
391
+ let stat = await fsP.stat(entryPath).catch(() => undefined)
392
+ if (stat === undefined) continue
393
+ if (stat.isDirectory()) { await walk(entryPath) }
394
+ else if (stat.isFile()) { fileCount += 1; totalSize += stat.size }
395
+ }
396
+ }
397
+ await walk(dir)
398
+ return { fileCount, totalSize }
399
+ }
400
+
401
+ async function copyDir(from, to) {
402
+ await fsP.mkdir(to, { recursive: true })
403
+ const entries = await fsP.readdir(from, { withFileTypes: true })
404
+ for (const entry of entries) {
405
+ if (entry.name === '.git') continue
406
+ const source = join(from, entry.name), target = join(to, entry.name)
407
+ let stat
408
+ try { stat = await fsP.stat(source) } catch { continue }
409
+ if (stat.isDirectory()) { await copyDir(source, target) }
410
+ else if (stat.isFile()) { await fsP.copyFile(source, target) }
411
+ }
412
+ }
413
+
414
+ async function atomicWriteJs(file, content) {
415
+ await fsP.mkdir(join(file, '..'), { recursive: true })
416
+ const temp = join(join(file, '..'), `.${randomUUID()}.tmp`)
417
+ await fsP.writeFile(temp, content, 'utf8')
418
+ await fsP.rename(temp, file)
419
+ }
420
+
421
+ // ── Market git sync(与 skills-management 同款;稀疏检出 experts/ 子树)──
422
+
423
+ function gitExec(binary, args, cwd) {
424
+ return new Promise((fulfil, reject) => {
425
+ execFile(binary, args, { cwd, timeout: 10 * 60 * 1000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
426
+ if (error) {
427
+ const tail = String(stderr || error.message || '').split(/\r?\n/).filter(Boolean).slice(-3).join(' ')
428
+ reject(new Error(`git ${args[0]}: ${tail || error.message}`))
429
+ return
430
+ }
431
+ fulfil(String(stdout).trim())
432
+ })
433
+ })
434
+ }
435
+
436
+ async function gitAvailable(binary) {
437
+ try { await gitExec(binary, ['--version']); return true } catch { return false }
438
+ }
439
+
440
+ async function gitCurrentCommit(binary, repo) {
441
+ try { return await gitExec(binary, ['rev-parse', 'HEAD'], repo) } catch { return undefined }
442
+ }
443
+
444
+ async function gitRemoteCommit(binary, repo, remote, branch) {
445
+ try {
446
+ const out = await gitExec(binary, ['ls-remote', '--heads', remote, branch], repo)
447
+ return out.split(/\s+/)[0] || undefined
448
+ } catch { return undefined }
449
+ }
450
+
451
+ function authedUrl(url, token) {
452
+ if (!token) return url
453
+ return String(url).replace(/^(https?:\/\/)([^@/]+@)?/, `$1oauth2:${encodeURIComponent(token)}@`)
454
+ }
455
+
456
+ /** Clone (first time) or fetch+reset (update); sparse checkout只落地指定子树。 */
457
+ async function gitSyncRepo(binary, url, branch, repoDir, token, sparsePaths) {
458
+ const remote = authedUrl(url, token)
459
+ const sparse = Array.isArray(sparsePaths) && sparsePaths.length > 0 ? sparsePaths : undefined
460
+ let repoExists = false
461
+ try { await fsP.access(join(repoDir, '.git')); repoExists = true } catch { repoExists = false }
462
+ if (!repoExists) {
463
+ await fsP.rm(repoDir, { recursive: true, force: true })
464
+ await fsP.mkdir(join(repoDir, '..'), { recursive: true })
465
+ if (sparse) {
466
+ await gitExec(binary, ['clone', '-b', branch, '--depth', '1', '--filter=blob:none', '--sparse', remote, repoDir])
467
+ await gitExec(binary, ['sparse-checkout', 'set', '--cone', ...sparse], repoDir)
468
+ } else {
469
+ await gitExec(binary, ['clone', '-b', branch, '--depth', '1', remote, repoDir])
470
+ }
471
+ return { isFirstClone: true, hasUpdates: true, before: undefined, after: await gitCurrentCommit(binary, repoDir) }
472
+ }
473
+ if (sparse) {
474
+ try { await gitExec(binary, ['sparse-checkout', 'set', '--cone', ...sparse], repoDir) }
475
+ catch (e) { console.warn(`experts-management: sparse-checkout conversion failed, continuing full: ${e && e.message}`) }
476
+ }
477
+ const before = await gitCurrentCommit(binary, repoDir)
478
+ await gitExec(binary, ['fetch', remote, branch], repoDir)
479
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir)
480
+ const after = await gitCurrentCommit(binary, repoDir)
481
+ return { isFirstClone: false, hasUpdates: before !== after, before, after }
482
+ }
483
+
484
+ const DEFAULT_MARKET_SYNC = {
485
+ url: 'https://gitcode.com/weibaohui/ntd-resource.git',
486
+ branch: 'main',
487
+ gitBinary: 'git',
488
+ autoSync: true, // periodic: sync when lastSyncAt is older than a day
489
+ syncOnStartup: true,
490
+ }
491
+ // ntd-resource 同时携带 skills(~400MB),专家市场只稀疏检出 experts/ 子树
492
+ const DEFAULT_MARKET_SPARSE_PATHS = ['experts']
493
+
494
+ const MARKET_SETTINGS_NS = 'experts-management-market'
495
+
496
+ function marketSettingsSchema() {
497
+ if (!Schema) return null
498
+ return Schema.object({
499
+ url: Schema.string(),
500
+ branch: Schema.string(),
501
+ gitBinary: Schema.string(),
502
+ repoDir: Schema.string(),
503
+ autoSync: Schema.boolean(),
504
+ syncOnStartup: Schema.boolean(),
505
+ token: Schema.string(),
506
+ })
507
+ }
508
+
509
+ function baseSettings(config) {
510
+ const cfg = (config.marketSync && typeof config.marketSync === 'object') ? config.marketSync : {}
511
+ const base = { ...DEFAULT_MARKET_SYNC }
512
+ for (const key of ['url', 'branch', 'gitBinary', 'autoSync', 'syncOnStartup']) {
513
+ if (cfg[key] !== undefined) base[key] = cfg[key]
514
+ }
515
+ if (config.marketRepoDir !== undefined) base.repoDir = resolve(String(config.marketRepoDir))
516
+ return base
517
+ }
518
+
519
+ // ── Module export ────────────────────────────────────────────────────────
520
+
521
+ module.exports = {
522
+ name: 'experts-management',
523
+ inject: ['skills', 'webServer', 'settings'],
524
+ __internals: {
525
+ extractFrontmatter, parseFrontmatter, parseAgentMd, parseSkillMd, parsePluginJson,
526
+ localized, truncateDescription, resolveWithin, isSafeExpertName,
527
+ buildSkillsContext, buildExpertPrompt, resolveLeadAgentFile,
528
+ candidateName: (name) => EXPERT_NAME_PREFIX + name,
529
+ SOURCE_DEFS, EXPERT_NAME_PREFIX, KEBAB_NAME_RE,
530
+ },
531
+
532
+ apply(ctx, config = {}) {
533
+ const installedDir = resolve(String(config.installedDir !== undefined ? config.installedDir : join(dshHome(), 'experts')))
534
+ const providerName = config.providerName !== undefined ? config.providerName : 'ntd-experts'
535
+
536
+ // ── Source rows:内置只有 dsh 用户库;其他目录一律经 extraSources 显式加入 ──
537
+ const disabledSources = new Set(Array.isArray(config.disabledSources) ? config.disabledSources : [])
538
+ const seenKeys = new Set()
539
+ const sourceRows = []
540
+ for (const def of SOURCE_DEFS) {
541
+ if (disabledSources.has(def.key)) continue
542
+ const root = def.key === 'dsh' ? installedDir : def.dir !== undefined ? resolve(String(def.dir)) : undefined
543
+ if (seenKeys.has(def.key)) continue
544
+ seenKeys.add(def.key)
545
+ sourceRows.push({ key: def.key, label: def.label, root, readOnly: def.readOnly === true })
546
+ }
547
+ for (const extra of Array.isArray(config.extraSources) ? config.extraSources : []) {
548
+ if (extra === null || typeof extra !== 'object') continue
549
+ if (typeof extra.key !== 'string' || extra.key === '') continue
550
+ if (typeof extra.dir !== 'string' || extra.dir === '') continue
551
+ if (seenKeys.has(extra.key)) continue
552
+ seenKeys.add(extra.key)
553
+ sourceRows.push({
554
+ key: extra.key,
555
+ label: typeof extra.label === 'string' && extra.label !== '' ? extra.label : extra.key,
556
+ root: resolve(String(extra.dir)),
557
+ readOnly: extra.readOnly === true,
558
+ })
559
+ }
560
+ const findSourceRow = (key) => allSourceRows().find((row) => row.key === key)
561
+ // git 市场检出(稀疏 experts/ 子树)也是一路来源:root 运行期可变,按调用时解析
562
+ const allSourceRows = () => [
563
+ ...sourceRows,
564
+ { key: 'market', label: '专家市场', root: join(marketRootDir(), 'experts'), readOnly: true },
565
+ ]
566
+
567
+ // ── Market sync state / settings(skills-management 同款管线)────────
568
+ const marketRootDir = () => {
569
+ const eff = marketSettings()
570
+ return resolve(typeof eff.repoDir === 'string' && eff.repoDir !== '' ? eff.repoDir
571
+ : config.marketRepoDir !== undefined ? resolve(String(config.marketRepoDir))
572
+ : join(dshHome(), 'experts-management', 'market'))
573
+ }
574
+ const marketSparsePaths = () => config.marketSparsePaths === null
575
+ ? undefined
576
+ : (Array.isArray(config.marketSparsePaths) && config.marketSparsePaths.length > 0 ? config.marketSparsePaths.map(String) : DEFAULT_MARKET_SPARSE_PATHS)
577
+
578
+ const marketStateFile = join(dshHome(), 'experts-management', 'market-sync.json')
579
+ let marketState = { lastSyncAt: undefined, lastResult: undefined }
580
+ let settingsScope = null
581
+ const settingsOverrides = {} // fallback sheet when the settings service is absent
582
+ const marketStateLoaded = fsP.readFile(marketStateFile, 'utf8')
583
+ .then(raw => {
584
+ const parsed = JSON.parse(raw)
585
+ marketState = { lastSyncAt: parsed.lastSyncAt, lastResult: parsed.lastResult }
586
+ })
587
+ .catch(() => {})
588
+ if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
589
+ try {
590
+ settingsScope = ctx.settings.register(MARKET_SETTINGS_NS, marketSettingsSchema(), { base: baseSettings(config) })
591
+ } catch (e) { ctx.logger.warn(`experts-management: settings register: ${e && e.message}`) }
592
+ }
593
+ const saveMarketState = async () => {
594
+ try {
595
+ await fsP.mkdir(join(marketStateFile, '..'), { recursive: true })
596
+ await atomicWriteJs(marketStateFile, JSON.stringify(marketState, null, 2))
597
+ await fsP.chmod(marketStateFile, 0o600)
598
+ } catch {}
599
+ }
600
+ const marketSettings = () => {
601
+ if (settingsScope && typeof settingsScope.get === 'function') {
602
+ const v = settingsScope.get()
603
+ if (v && typeof v === 'object') return { ...baseSettings(config), ...v }
604
+ }
605
+ return { ...baseSettings(config), ...settingsOverrides }
606
+ }
607
+
608
+ let marketSyncRun = null
609
+ const runMarketSync = async () => {
610
+ if (marketSyncRun !== null) return marketSyncRun
611
+ marketSyncRun = (async () => {
612
+ await marketStateLoaded
613
+ const eff = marketSettings()
614
+ const ok = await gitAvailable(eff.gitBinary)
615
+ if (!ok) throw new Error('git is not available on PATH')
616
+ const started = Date.now()
617
+ const repoDir = marketRootDir()
618
+ const result = await gitSyncRepo(eff.gitBinary, eff.url, eff.branch, repoDir, eff.token, marketSparsePaths())
619
+ marketState.lastSyncAt = new Date().toISOString()
620
+ marketState.lastResult = { ...result, at: marketState.lastSyncAt, durationMs: Date.now() - started }
621
+ await saveMarketState()
622
+ invalidate()
623
+ return { ...marketState.lastResult, url: eff.url, branch: eff.branch, dir: repoDir }
624
+ })().finally(() => { marketSyncRun = null })
625
+ return marketSyncRun
626
+ }
627
+
628
+ // Startup + periodic auto-sync (fire-and-forget; failures only warn)
629
+ ctx.effect(() => {
630
+ const eff = marketSettings()
631
+ if (eff.syncOnStartup) {
632
+ marketStateLoaded.then(() => runMarketSync()).catch(e => ctx.logger.warn(`experts-management: startup market sync: ${e && e.message}`))
633
+ }
634
+ const timer = setInterval(() => {
635
+ const eff2 = marketSettings()
636
+ if (!eff2.autoSync) return
637
+ const last = marketState.lastSyncAt ? Date.parse(marketState.lastSyncAt) : 0
638
+ if (Date.now() - last > 24 * 3600 * 1000) {
639
+ runMarketSync().catch(e => ctx.logger.warn(`experts-management: auto market sync: ${e && e.message}`))
640
+ }
641
+ }, 6 * 3600 * 1000)
642
+ if (typeof timer.unref === 'function') timer.unref()
643
+ return () => clearInterval(timer)
644
+ }, 'experts-management: market auto-sync')
645
+
646
+ // ── Discovery ────────────────────────────────────────────────────────
647
+ async function discoverAll() {
648
+ const installed = [], market = []
649
+ const errors = []
650
+ for (const row of allSourceRows()) {
651
+ const { experts, errors: errs } = await scanExpertsRoot(row.root, row.key)
652
+ errors.push(...errs)
653
+ if (row.key === 'dsh') installed.push(...experts)
654
+ else market.push(...experts)
655
+ }
656
+ return { installed, market, errors }
657
+ }
658
+
659
+ async function locateExpert(name, sourceKey) {
660
+ const rows = sourceKey !== undefined && sourceKey !== null && sourceKey !== '' && sourceKey !== 'auto'
661
+ ? [findSourceRow(sourceKey)].filter(Boolean)
662
+ : allSourceRows()
663
+ if (sourceKey !== undefined && sourceKey !== null && sourceKey !== '' && sourceKey !== 'auto' && findSourceRow(sourceKey) === undefined) {
664
+ throw new Error(`unknown source '${sourceKey}'`)
665
+ }
666
+ for (const row of rows) {
667
+ const { experts } = await scanExpertsRoot(row.root, row.key)
668
+ const hit = experts.find((e) => e.name === name)
669
+ if (hit !== undefined) return { expert: hit, row }
670
+ }
671
+ throw new Error(`expert '${name}' not found${sourceKey ? ` in ${sourceKey}` : ''}`)
672
+ }
673
+
674
+ // ── Skill provider:每个专家 = 仅用户可调用的技能 ────────────────────
675
+ let providerControl
676
+ const invalidate = () => { if (providerControl !== undefined) providerControl.invalidate() }
677
+
678
+ ctx.skills.registerProvider((control) => {
679
+ providerControl = control
680
+ control.signal.addEventListener('abort', () => { if (providerControl === control) providerControl = undefined }, { once: true })
681
+ return {
682
+ name: providerName,
683
+ async list() {
684
+ const { installed, market } = await discoverAll()
685
+ const candidates = []
686
+ const seen = new Set()
687
+ // 专家一律 modelInvocable:false:不进模型目录(零 token 污染),
688
+ // 仅保留 /expert-名称 用户手势(宿主 pre-step 确定性注入 <skill_content>)。
689
+ const invocation = { modelInvocable: false, userInvocable: true }
690
+ const describe = (e) => truncateDescription([e.professionZh ?? e.professionEn, e.descZh ?? e.descEn].filter(Boolean).join(' · '))
691
+ const push = (e, source, rank) => {
692
+ const candName = EXPERT_NAME_PREFIX + e.name
693
+ if (!KEBAB_NAME_RE.test(candName)) {
694
+ ctx.logger.warn(`experts-management: skipping expert '${e.name}' (${e.dir}): invalid candidate name '${candName}'`)
695
+ return
696
+ }
697
+ if (describe(e) === '') {
698
+ ctx.logger.warn(`experts-management: skipping expert '${e.name}' (${e.dir}): empty description`)
699
+ return
700
+ }
701
+ if (seen.has(candName)) return
702
+ seen.add(candName)
703
+ candidates.push({
704
+ name: candName,
705
+ description: describe(e),
706
+ invocation,
707
+ source,
708
+ provider: providerName,
709
+ rank,
710
+ locator: { name: e.name, dir: e.dir, source: e.source, agent: (resolveLeadAgentFile(e) || {}).mdPath },
711
+ path: (resolveLeadAgentFile(e) || {}).mdPath,
712
+ resourceBase: { kind: 'directory', path: e.dir },
713
+ metadata: { expertType: e.expertType, profession: e.professionZh ?? e.professionEn, version: e.version },
714
+ })
715
+ }
716
+ for (const e of installed) push(e, 'user-installed', RANK_INSTALLED)
717
+ const installedNames = new Set(installed.map((e) => e.name))
718
+ for (const e of market) {
719
+ if (installedNames.has(e.name)) continue // 用户库覆盖市场同名专家
720
+ push(e, 'market', RANK_MARKET)
721
+ }
722
+ return candidates
723
+ },
724
+ async get(candidate) {
725
+ try {
726
+ // list→get 之间文件可能变化:按 locator 重新读取角色定义
727
+ const pluginRaw = await fsP.readFile(join(candidate.locator.dir, PLUGIN_JSON_REL), 'utf8')
728
+ const expert = { ...parsePluginJson(pluginRaw), dir: candidate.locator.dir }
729
+ const agentPath = candidate.locator.agent
730
+ if (agentPath === undefined) return undefined
731
+ const mdRaw = await fsP.readFile(agentPath, 'utf8')
732
+ const { body } = parseFrontmatter(mdRaw)
733
+ // 技能清单现场重建:链接指向当前磁盘上的 SKILL.md
734
+ const head = expert
735
+ const skillMeta = []
736
+ for (const rel of head.skills) {
737
+ const skillDir = resolveWithin(candidate.locator.dir, rel)
738
+ if (skillDir === undefined) continue
739
+ try {
740
+ const parsed = parseSkillMd(await fsP.readFile(join(skillDir, 'SKILL.md'), 'utf8'))
741
+ skillMeta.push({ ...parsed, skillName: parsed.name ?? basename(skillDir), skillMdPath: join(skillDir, 'SKILL.md') })
742
+ } catch { /* skip */ }
743
+ }
744
+ return {
745
+ name: candidate.name,
746
+ description: candidate.description,
747
+ invocation: { modelInvocable: false, userInvocable: true },
748
+ source: candidate.source,
749
+ provider: providerName,
750
+ resourceBase: { kind: 'directory', path: candidate.locator.dir },
751
+ content: buildExpertPrompt(body, buildSkillsContext(skillMeta), expert),
752
+ path: agentPath,
753
+ metadata: candidate.metadata,
754
+ }
755
+ } catch { return undefined }
756
+ },
757
+ }
758
+ })
759
+
760
+ // ── HTTP API ─────────────────────────────────────────────────────────
761
+ ctx.effect(() => ctx.webServer.register({
762
+ kind: 'prefix',
763
+ path: '/experts-management/api',
764
+ handler: async (req, res) => {
765
+ try {
766
+ const url = new URL(req.url || '/', 'http://dsh.local')
767
+ const apiPath = url.pathname.replace(/\/+$/, '')
768
+ const query = url.searchParams
769
+
770
+ // GET /experts-management/api → { sources, installed, market }
771
+ if (req.method === 'GET' && apiPath === '/experts-management/api') {
772
+ const { installed, market, errors } = await discoverAll()
773
+ const installedNames = new Set(installed.map((e) => e.name))
774
+ const summarize = (e) => ({
775
+ name: e.name,
776
+ displayName: e.displayNameZh ?? e.displayNameEn ?? e.name,
777
+ profession: e.professionZh ?? e.professionEn ?? '',
778
+ description: truncateDescription(e.descZh ?? e.descEn ?? ''),
779
+ expertType: e.expertType,
780
+ tags: e.tags.filter((t) => t.zh || t.en).map((t) => t.zh || t.en),
781
+ hasAvatar: e.avatar !== undefined,
782
+ source: e.source,
783
+ installed: installedNames.has(e.name),
784
+ mtime: e.mtime,
785
+ })
786
+ sendJson(res, 200, {
787
+ sources: allSourceRows().map((row) => ({ key: row.key, label: row.label, dir: displayPath(row.root), readOnly: row.readOnly })),
788
+ installed: installed.map(summarize),
789
+ market: market.map(summarize),
790
+ errors,
791
+ })
792
+ return
793
+ }
794
+
795
+ // GET /experts-management/api/detail?name=&source=
796
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/detail')) {
797
+ const name = query.get('name') || ''
798
+ const { expert, row } = await locateExpert(name, query.get('source') || undefined)
799
+ const { fileCount, totalSize } = await countFilesAndSize(expert.dir)
800
+ sendJson(res, 200, {
801
+ ...expert,
802
+ plugin: parsePluginJson(await fsP.readFile(expert.pluginJsonPath, 'utf8')),
803
+ leadAgentFile: resolveLeadAgentFile(expert)?.name,
804
+ dir: displayPath(expert.dir),
805
+ sourceLabel: row.label,
806
+ readOnly: row.readOnly,
807
+ fileCount, totalSize,
808
+ })
809
+ return
810
+ }
811
+
812
+ // GET /experts-management/api/agent-md?name=&source=&agent=
813
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/agent-md')) {
814
+ const { expert } = await locateExpert(query.get('name') || '', query.get('source') || undefined)
815
+ const wanted = query.get('agent') || undefined
816
+ const normRel = (p) => String(p || '').replace(/^\.\//, '')
817
+ const agentFile = wanted !== undefined
818
+ ? expert.agentFiles.find((a) => a.name === wanted || normRel(a.relPath) === normRel(wanted) || basename(a.mdPath) === wanted)
819
+ : resolveLeadAgentFile(expert)
820
+ if (agentFile === undefined) throw new Error(`agent not found in expert '${expert.name}'`)
821
+ sendJson(res, 200, { expert: expert.name, agent: agentFile.name, content: await fsP.readFile(agentFile.mdPath, 'utf8') })
822
+ return
823
+ }
824
+
825
+ // GET /experts-management/api/avatar?name=&source=&member=
826
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/avatar')) {
827
+ const { expert } = await locateExpert(query.get('name') || '', query.get('source') || undefined)
828
+ const memberId = query.get('member')
829
+ let rel
830
+ if (memberId !== null && memberId !== '') {
831
+ const member = expert.members.find((m) => m.id === memberId)
832
+ rel = member !== undefined ? member.avatar : undefined
833
+ } else {
834
+ rel = expert.avatar
835
+ }
836
+ if (rel === undefined) { res.writeHead(404); res.end(); return }
837
+ const full = resolveWithin(expert.dir, rel)
838
+ if (full === undefined) { res.writeHead(404); res.end(); return }
839
+ try { await sendFile(res, full) } catch { res.writeHead(404); res.end() }
840
+ return
841
+ }
842
+
843
+ // GET /experts-management/api/file?name=&source=&path= (预览 references 等)
844
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/file')) {
845
+ const { expert } = await locateExpert(query.get('name') || '', query.get('source') || undefined)
846
+ const rel = query.get('path') || ''
847
+ const full = resolveWithin(expert.dir, rel)
848
+ if (full === undefined) throw new Error('invalid file path')
849
+ await sendFile(res, full)
850
+ return
851
+ }
852
+
853
+ // POST /experts-management/api/install {name, from?, overwrite?}
854
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/install')) {
855
+ const body = await readJsonBody(req)
856
+ if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
857
+ const { expert } = await locateExpert(body.name, typeof body.from === 'string' && body.from !== '' && body.from !== 'market' ? body.from : undefined)
858
+ if (!isSafeExpertName(expert.name)) throw new Error(`invalid expert name: ${expert.name}`)
859
+ const target = join(installedDir, expert.name)
860
+ if (body.overwrite !== true) {
861
+ try { await fsP.access(target); throw new Error(`expert '${expert.name}' already installed`) }
862
+ catch (e) { if (e.code !== 'ENOENT') throw e }
863
+ } else {
864
+ await fsP.rm(target, { recursive: true, force: true })
865
+ }
866
+ await copyDir(expert.dir, target)
867
+ invalidate()
868
+ sendJson(res, 201, { installed: { name: expert.name, dir: target, from: expert.source } })
869
+ return
870
+ }
871
+
872
+ // DELETE /experts-management/api {name} → 仅允许删除 dsh 用户库
873
+ if (req.method === 'DELETE' && apiPath.endsWith('/experts-management/api')) {
874
+ const body = await readJsonBody(req)
875
+ if (typeof body.name !== 'string' || body.name === '') { sendJson(res, 400, { error: 'body must provide name' }); return }
876
+ if (!isSafeExpertName(body.name)) throw new Error('invalid expert name')
877
+ const target = join(installedDir, body.name)
878
+ const stat = await fsP.stat(target).catch(() => undefined)
879
+ if (stat === undefined || !stat.isDirectory()) throw new Error(`expert '${body.name}' not found in the dsh library`)
880
+ await fsP.rm(target, { recursive: true })
881
+ invalidate()
882
+ sendJson(res, 200, { removed: body.name, source: 'dsh' })
883
+ return
884
+ }
885
+
886
+ // GET /experts-management/api/market/status
887
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/market/status')) {
888
+ await marketStateLoaded
889
+ const eff = marketSettings()
890
+ const repoDir = marketRootDir()
891
+ const repoExists = await fsP.access(join(repoDir, '.git')).then(() => true).catch(() => false)
892
+ const ok = await gitAvailable(eff.gitBinary)
893
+ const [localCommit, remoteCommit] = repoExists && ok
894
+ ? [await gitCurrentCommit(eff.gitBinary, repoDir), await gitRemoteCommit(eff.gitBinary, repoDir, 'origin', eff.branch)]
895
+ : [undefined, undefined]
896
+ sendJson(res, 200, {
897
+ url: eff.url, branch: eff.branch, dir: displayPath(repoDir),
898
+ gitAvailable: ok, repoExists,
899
+ localCommit, remoteCommit,
900
+ needsUpdate: localCommit !== undefined && remoteCommit !== undefined ? localCommit !== remoteCommit : undefined,
901
+ lastSyncAt: marketState.lastSyncAt, lastResult: marketState.lastResult,
902
+ autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
903
+ hasToken: typeof eff.token === 'string' && eff.token !== '',
904
+ syncing: marketSyncRun !== null,
905
+ sparsePaths: marketSparsePaths() ?? null,
906
+ })
907
+ return
908
+ }
909
+
910
+ // POST /experts-management/api/market/sync
911
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/market/sync')) {
912
+ try {
913
+ const result = await runMarketSync()
914
+ sendJson(res, 200, result)
915
+ } catch (e) { sendJson(res, 400, { error: String(e && e.message || e) }) }
916
+ return
917
+ }
918
+
919
+ // PUT /experts-management/api/market/settings {url?, branch?, repoDir?, token?, autoSync?, syncOnStartup?}
920
+ if (req.method === 'PUT' && apiPath.endsWith('/experts-management/api/market/settings')) {
921
+ const body = await readJsonBody(req)
922
+ await marketStateLoaded
923
+ const patch = {}
924
+ for (const key of ['url', 'branch', 'gitBinary']) {
925
+ if (typeof body[key] === 'string' && body[key] !== '') patch[key] = body[key]
926
+ }
927
+ if (typeof body.token === 'string' && body.token !== '') patch.token = body.token
928
+ if (body.token === null || body.token === '') patch.token = undefined
929
+ if (typeof body.repoDir === 'string' && body.repoDir !== '') patch.repoDir = resolve(body.repoDir)
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
+ sendJson(res, 404, { error: 'not found' })
945
+ } catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
946
+ },
947
+ }), 'experts-management: api route')
948
+ },
949
+ }