@dsh-plus/agent-preset-rea 0.1.0 → 0.1.2

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/reverse.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * 第二个受管技能源:GitHub 仓库 `zhaoxuya520/reverse-skill`(路由包:根技能
3
+ * `reverse-skill-router` + 各模块技能目录)。
4
+ *
5
+ * 与 REA 包分开成两个源的理由:来源不同(npm registry vs GitHub 仓库)、版本语义
6
+ * 不同(semver 钉版 vs 提交 sha)、挂载形状不同(单技能根 vs 多技能组),混在一个
7
+ * 作业里会让失败语义含混(npm 成功、GitHub 挂掉时到底算不算成功)。
8
+ *
9
+ * 落点:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/reverse-skill/`
10
+ * - `repo/`:仓库树(`tar --strip-components=1` 解开,不改内容);
11
+ * - `manifest.json`:来源溯源(repo / ref / sha / VERSION / 技能组 / 更新时间)。
12
+ *
13
+ * 技能组发现规则:树里**直接子目录带 `SKILL.md`** 的目录即一个可挂载根(DSH 的
14
+ * skill 扫描正是「根的直接子项 = 技能」)。树根自身不算——它的散装 `.md`
15
+ * (README/AGENTS/RULES 等)会被当成技能文件解析;`plugins/**` 是各客户端的
16
+ * 打包产物(重复一遍路由技能),也不暴露。
17
+ *
18
+ * 作业先落 staging 再换目录:中断只留 staging,运行中的会话继续读旧树。
19
+ * @module @dsh-plus/agent-preset-rea/reverse
20
+ */
21
+ import { type Dirent, existsSync, readdirSync, readFileSync } from 'node:fs'
22
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
23
+ import { join } from 'node:path'
24
+
25
+ import { pluginDataPath } from '@dsh-plus/shared'
26
+
27
+ import type { AgentPresetReaConfig } from './config.ts'
28
+ import {
29
+ commandEnv,
30
+ countFiles,
31
+ type JobDeps,
32
+ type JobTaskContext,
33
+ lineSink,
34
+ proxyUrl,
35
+ } from './exec.ts'
36
+
37
+ /** 插件数据目录标识(与 manager.ts 同源:`<DSH_HOME>/dsh-plus/<id>`)。 */
38
+ const PLUGIN_ID = 'dsh-plus-agent-preset-rea'
39
+
40
+ /** 本技能源在插件数据目录下的子目录名。 */
41
+ export const REVERSE_DIR = 'reverse-skill'
42
+
43
+ /** 仓库树目录名(换目录作业的替换单元是它的父目录)。 */
44
+ export const REVERSE_TREE_DIR = 'repo'
45
+
46
+ /** 默认仓库。 */
47
+ export const REVERSE_DEFAULT_REPO = 'zhaoxuya520/reverse-skill'
48
+
49
+ /** 手动更新作业阶段名。 */
50
+ export const REVERSE_PHASES: string[] = ['解析仓库信息', '下载源码包', '解包源码', '校验并写入清单']
51
+
52
+ /** 不参与技能组发现的目录名(客户端打包产物;会重复一遍路由技能)。 */
53
+ const SKIP_DIRS = new Set(['.git', 'plugins', 'node_modules'])
54
+
55
+ /** 受管路径。 */
56
+ export interface ReversePaths {
57
+ /** 技能源根(`repo/` 与 `manifest.json` 的父目录)。 */
58
+ root: string
59
+ /** 仓库树根。 */
60
+ tree: string
61
+ }
62
+
63
+ /** 清单(溯源事实;sha 已是内容级溯源,故不再逐文件复算哈希)。 */
64
+ export interface ReverseManifest {
65
+ source: string
66
+ repo: string
67
+ /** 配置里的 ref(空串 = 仓库默认分支)。 */
68
+ ref: string
69
+ /** GitHub 机构 API 解析到的提交 sha。 */
70
+ sha: string
71
+ /** 仓库 `VERSION` 文件内容(无则 null)。 */
72
+ version: string | null
73
+ /** 树内文件数。 */
74
+ files: number
75
+ /** 树内发现的技能组(相对路径)。 */
76
+ groups: string[]
77
+ updatedAt: string
78
+ }
79
+
80
+ /** 技能源现状。 */
81
+ export interface ReverseState {
82
+ installed: boolean
83
+ repo: string
84
+ ref: string | null
85
+ sha: string | null
86
+ version: string | null
87
+ files: number
88
+ /** 树内发现的全部技能组(相对路径)。 */
89
+ groups: string[]
90
+ updatedAt: string | null
91
+ }
92
+
93
+ /** 未安装时的现状(导出给 emptyRuntime 等默认场景用)。 */
94
+ export function emptyReverseState(repo = REVERSE_DEFAULT_REPO): ReverseState {
95
+ return missingState(repo)
96
+ }
97
+
98
+ /** 未安装时的现状。 */
99
+ function missingState(repo: string): ReverseState {
100
+ return {
101
+ installed: false,
102
+ repo,
103
+ ref: null,
104
+ sha: null,
105
+ version: null,
106
+ files: 0,
107
+ groups: [],
108
+ updatedAt: null,
109
+ }
110
+ }
111
+
112
+ /** 受管路径(固定落插件数据目录,无部署方可调项)。 */
113
+ export function reversePaths(): ReversePaths {
114
+ const root = pluginDataPath(PLUGIN_ID, REVERSE_DIR)
115
+ return { root, tree: join(root, REVERSE_TREE_DIR) }
116
+ }
117
+
118
+ /** 读 JSON(不存在或坏内容返回 null)。 */
119
+ function readJson(file: string): unknown {
120
+ try {
121
+ return JSON.parse(readFileSync(file, 'utf8')) as unknown
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ /** 记录型守卫。 */
128
+ function asRecord(value: unknown): Record<string, unknown> | null {
129
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
130
+ }
131
+
132
+ /** 直接子目录名(跳过不参与发现的目录;目录读不到按无子目录)。 */
133
+ function subdirs(dir: string): string[] {
134
+ let entries: Dirent[]
135
+ try {
136
+ entries = readdirSync(dir, { withFileTypes: true })
137
+ } catch {
138
+ return []
139
+ }
140
+ return entries
141
+ .filter((entry) => entry.isDirectory() && !SKIP_DIRS.has(entry.name))
142
+ .map((entry) => entry.name)
143
+ .sort()
144
+ }
145
+
146
+ /** 发现树内的技能组:直接子目录带 SKILL.md 的目录(排除树根与 SKIP_DIRS)。 */
147
+ export function discoverSkillGroups(tree: string): string[] {
148
+ const groups: string[] = []
149
+ const walk = (rel: string): void => {
150
+ const abs = rel === '' ? tree : join(tree, rel)
151
+ const names = subdirs(abs)
152
+ if (names.some((name) => existsSync(join(abs, name, 'SKILL.md')))) groups.push(rel)
153
+ for (const name of names) {
154
+ if (SKIP_DIRS.has(name)) continue
155
+ walk(rel === '' ? name : `${rel}/${name}`)
156
+ }
157
+ }
158
+ if (existsSync(tree)) walk('')
159
+ return groups.filter((rel) => rel !== '')
160
+ }
161
+
162
+ /** 现探技能源(清单可读 + 树在 + 至少一个技能组才算可用)。 */
163
+ export function probeReverse(
164
+ config: AgentPresetReaConfig,
165
+ repo = config.reverseSkillRepo,
166
+ ): ReverseState {
167
+ const { root, tree } = reversePaths()
168
+ const manifest = asRecord(readJson(join(root, 'manifest.json')))
169
+ if (manifest === null || !existsSync(tree)) return missingState(repo)
170
+ const sha = typeof manifest['sha'] === 'string' ? manifest['sha'] : null
171
+ if (sha === null) return missingState(repo)
172
+ const groups = discoverSkillGroups(tree)
173
+ if (groups.length === 0) return missingState(repo)
174
+ return {
175
+ installed: true,
176
+ repo: typeof manifest['repo'] === 'string' ? manifest['repo'] : repo,
177
+ ref: typeof manifest['ref'] === 'string' ? manifest['ref'] : '',
178
+ sha,
179
+ version: typeof manifest['version'] === 'string' ? manifest['version'] : null,
180
+ files: typeof manifest['files'] === 'number' ? manifest['files'] : countFiles(tree),
181
+ groups,
182
+ updatedAt: typeof manifest['updatedAt'] === 'string' ? manifest['updatedAt'] : null,
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 挂进本预设的技能组目录:配置选中 ∩ 树内发现。
188
+ *
189
+ * 组是否真实存在由现探的 {@link discoverSkillGroups} 负责(发现即存在),这里不再
190
+ * 二次查盘——否则「未安装/组缺席」两种语义会在两处各判一遍。
191
+ * @param config - 插件配置。
192
+ * @param state - 现探现状。
193
+ */
194
+ export function reverseSkillDirs(config: AgentPresetReaConfig, state: ReverseState): string[] {
195
+ if (!state.installed) return []
196
+ const { tree } = reversePaths()
197
+ return config.reverseSkillGroups
198
+ .map((group) => group.trim())
199
+ .filter((group) => group !== '' && state.groups.includes(group))
200
+ .map((group) => join(tree, group))
201
+ }
202
+
203
+ /** 作业依赖(与 manager 的作业同形:配置 + 命令执行面 + 现探刷新)。 */
204
+ export type ReverseDeps = JobDeps
205
+
206
+ /** curl 通用参数:失败即非零退出、跟随跳转、静默进度。 */
207
+ function curlArgs(config: AgentPresetReaConfig): string[] {
208
+ const proxy = proxyUrl(config)
209
+ return ['-fsSL', ...(proxy === null ? [] : ['-x', proxy])]
210
+ }
211
+
212
+ /** 跑一次 curl 并落文件(失败抛错)。 */
213
+ async function curl(
214
+ deps: ReverseDeps,
215
+ ctx: JobTaskContext,
216
+ args: string[],
217
+ target: string,
218
+ ): Promise<void> {
219
+ const result = await deps.runner.run('curl', [...curlArgs(deps.config), '-o', target, ...args], {
220
+ env: commandEnv(deps.config),
221
+ signal: ctx.signal,
222
+ onLine: lineSink(ctx),
223
+ })
224
+ if (result.code !== 0) throw new Error(`curl 失败(退出码 ${result.code}):${args.join(' ')}`)
225
+ }
226
+
227
+ /** GitHub API 取仓库信息(默认分支)与提交 sha。 */
228
+ async function resolveRepo(
229
+ deps: ReverseDeps,
230
+ ctx: JobTaskContext,
231
+ staging: string,
232
+ ): Promise<{ sha: string; ref: string }> {
233
+ ctx.phase(0)
234
+ const repo = deps.config.reverseSkillRepo
235
+ const api = 'https://api.github.com'
236
+ const metaFile = join(staging, 'meta.json')
237
+ await curl(deps, ctx, [`${api}/repos/${repo}`], metaFile)
238
+ const meta = asRecord(readJson(metaFile))
239
+ const defaultBranch = typeof meta?.['default_branch'] === 'string' ? meta['default_branch'] : null
240
+ if (defaultBranch === null) throw new Error(`仓库信息不完整(${repo})`)
241
+
242
+ const ref =
243
+ deps.config.reverseSkillRef.trim() === '' ? defaultBranch : deps.config.reverseSkillRef.trim()
244
+ const commitFile = join(staging, 'commit.json')
245
+ await curl(deps, ctx, [`${api}/repos/${repo}/commits/${ref}`], commitFile)
246
+ const commit = asRecord(readJson(commitFile))
247
+ const sha = typeof commit?.['sha'] === 'string' ? commit['sha'] : null
248
+ if (sha === null) throw new Error(`解析提交失败(${repo}@${ref})`)
249
+ ctx.log(`仓库 ${repo}:默认分支 ${defaultBranch},ref ${ref},提交 ${sha.slice(0, 12)}`)
250
+ return { sha, ref }
251
+ }
252
+
253
+ /** 下载源码包并解包到 `staging/tree`。 */
254
+ async function fetchTree(deps: ReverseDeps, ctx: JobTaskContext, staging: string): Promise<string> {
255
+ const { sha } = await resolveRepo(deps, ctx, staging)
256
+ // 树目录名与 {@link reversePaths} 对齐:staging 顶掉旧根后即 `<root>/repo`。
257
+ const tree = join(staging, REVERSE_TREE_DIR)
258
+ const tarball = join(staging, 'repo.tgz')
259
+
260
+ ctx.phase(1)
261
+ await curl(
262
+ deps,
263
+ ctx,
264
+ [`https://codeload.github.com/${deps.config.reverseSkillRepo}/tar.gz/${sha}`],
265
+ tarball,
266
+ )
267
+
268
+ ctx.phase(2)
269
+ await mkdir(tree, { recursive: true })
270
+ const untar = await deps.runner.run(
271
+ 'tar',
272
+ ['-xzf', tarball, '-C', tree, '--strip-components=1'],
273
+ { env: commandEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
274
+ )
275
+ if (untar.code !== 0) throw new Error(`tar 解包失败(退出码 ${untar.code})`)
276
+ return tree
277
+ }
278
+
279
+ /** 读仓库 VERSION(无则 null)。 */
280
+ function readVersion(tree: string): string | null {
281
+ try {
282
+ const text = readFileSync(join(tree, 'VERSION'), 'utf8').trim()
283
+ return text === '' ? null : (text.split('\n')[0] ?? null)
284
+ } catch {
285
+ return null
286
+ }
287
+ }
288
+
289
+ /** 手动更新作业:拉默认分支/指定 ref 的最新提交,解包后换目录并写清单。 */
290
+ export async function runReverseSkillJob(deps: ReverseDeps, ctx: JobTaskContext): Promise<string> {
291
+ const { root } = reversePaths()
292
+ const staging = `${root}.staging`
293
+ await rm(staging, { recursive: true, force: true })
294
+ await mkdir(staging, { recursive: true })
295
+ try {
296
+ const tree = await fetchTree(deps, ctx, staging)
297
+
298
+ ctx.phase(3)
299
+ const groups = discoverSkillGroups(tree)
300
+ if (groups.length === 0) throw new Error('仓库结构不含技能组(找不到带 SKILL.md 的子目录)')
301
+ const meta = asRecord(readJson(join(staging, 'commit.json')))
302
+ const sha = typeof meta?.['sha'] === 'string' ? meta['sha'] : ''
303
+ // 换目录前清掉作业草稿(tarball 与 API 响应都只是取数中间物,不该留在受管根里)。
304
+ for (const scratch of ['repo.tgz', 'meta.json', 'commit.json']) {
305
+ await rm(join(staging, scratch), { force: true })
306
+ }
307
+ const manifest: ReverseManifest = {
308
+ source: `github:${deps.config.reverseSkillRepo}@${sha}`,
309
+ repo: deps.config.reverseSkillRepo,
310
+ ref: deps.config.reverseSkillRef.trim(),
311
+ sha,
312
+ version: readVersion(tree),
313
+ files: countFiles(tree),
314
+ groups,
315
+ updatedAt: new Date().toISOString(),
316
+ }
317
+ await writeFile(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
318
+
319
+ // 换目录:staging 顶掉旧根,旧根留 .old 再清(失败时旧树仍完整可用)。
320
+ const backup = `${root}.old`
321
+ await rm(backup, { recursive: true, force: true })
322
+ if (existsSync(root)) await rename(root, backup)
323
+ await rename(staging, root)
324
+ await rm(backup, { recursive: true, force: true })
325
+
326
+ deps.refresh()
327
+ ctx.log(
328
+ `已更新 ${deps.config.reverseSkillRepo}@${sha.slice(0, 12)}:${manifest.files} 文件、技能组 ${groups.join(', ')}`,
329
+ )
330
+ return manifest.version ?? sha.slice(0, 12)
331
+ } catch (error) {
332
+ await rm(staging, { recursive: true, force: true })
333
+ throw error
334
+ }
335
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * 受管安装/更新的 HTTP 通道:状态查询 + 四类动作(安装/更新 REA、更新 REA skill、
3
+ * 更新 reverse-skill、取消作业)。
4
+ *
5
+ * 只挂在 dsh web 同源(webserver 默认 127.0.0.1,与 notify-email 的测试端点同等暴露面):
6
+ * 这些端点会 spawn npm/curl 并写插件数据目录,不做鉴权以外的额外暴露面扩张。
7
+ * 配置读写仍走官方 settings(卡片直连 remote.settings),这里只补官方传输没有的动作。
8
+ *
9
+ * 请求体只接受一个可选字段 `proxyPort`(0..65535);其余一律来自插件配置。
10
+ * @module @dsh-plus/agent-preset-rea/routes
11
+ */
12
+ import type { IncomingMessage, ServerResponse } from 'node:http'
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ // 平台声明导入(无值消费):把 webServer 服务面合进 program,ctx.webServer 方可解析。
16
+ import type {} from '@deepseek-ai/dsh-host-webserver'
17
+
18
+ import type { AgentPresetReaConfig } from './config.ts'
19
+ import type { JobActionDto, JobKind, StatusDto } from './dto.ts'
20
+ import type { JobBoard } from './jobs.ts'
21
+ import type { ReaManager } from './manager.ts'
22
+ import { type McpLaunch, reaMcpLaunch } from './mcp.ts'
23
+ import { reversePaths, reverseSkillDirs } from './reverse.ts'
24
+
25
+ /** 路由前缀。 */
26
+ export const ROUTE_BASE = '/dsh-plus/agent-preset-rea'
27
+
28
+ /** 请求体大小上限(只有一个数字字段)。 */
29
+ const BODY_LIMIT = 4096
30
+
31
+ /** 路由依赖。 */
32
+ export interface ReaRoutesOptions {
33
+ manager: ReaManager
34
+ board: JobBoard
35
+ config: () => AgentPresetReaConfig
36
+ }
37
+
38
+ /** 路由处理器形态。 */
39
+ type RouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
40
+
41
+ /** 响应发送器。 */
42
+ type Send = (res: ServerResponse, status: number, body: unknown) => void
43
+
44
+ /** 日志窄面。 */
45
+ interface RouteLogger {
46
+ info(message: string): void
47
+ warn(message: string): void
48
+ }
49
+
50
+ /** 读请求体(超限即拒绝;空体按空对象)。 */
51
+ async function readBody(req: IncomingMessage): Promise<unknown> {
52
+ const chunks: Buffer[] = []
53
+ let size = 0
54
+ for await (const chunk of req) {
55
+ const buf = chunk as Buffer
56
+ size += buf.length
57
+ if (size > BODY_LIMIT) throw new Error('请求体过大')
58
+ chunks.push(buf)
59
+ }
60
+ const text = Buffer.concat(chunks).toString('utf8').trim()
61
+ return text === '' ? {} : (JSON.parse(text) as unknown)
62
+ }
63
+
64
+ /** 从请求体取可选的代理端口覆盖(形状不对即抛,由调用方回 400)。 */
65
+ function proxyPortOf(body: unknown): number | undefined {
66
+ if (typeof body !== 'object' || body === null) return undefined
67
+ const value = (body as Record<string, unknown>)['proxyPort']
68
+ if (value === undefined || value === null) return undefined
69
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 65535) {
70
+ throw new Error('proxyPort 必须是 0..65535 的整数')
71
+ }
72
+ return value
73
+ }
74
+
75
+ /** 组装状态响应(纯函数,可直接单测)。 */
76
+ export function statusPayload(options: ReaRoutesOptions): StatusDto {
77
+ const config = options.config()
78
+ const runtime = options.manager.snapshot()
79
+ const paths = options.manager.paths()
80
+ const launch: McpLaunch | null = reaMcpLaunch(config, runtime.install)
81
+ const mode =
82
+ config.mcpCommand.trim() !== '' ? 'custom' : launch === null ? 'missing' : 'installed'
83
+ const reverse = reversePaths()
84
+ const activeDirs = reverseSkillDirs(config, runtime.reverse)
85
+ return {
86
+ config: {
87
+ reaVersion: config.reaVersion,
88
+ proxyPort: config.proxyPort,
89
+ mcpEnabled: config.mcpEnabled,
90
+ skillEnabled: config.skillEnabled,
91
+ reverseSkillEnabled: config.reverseSkillEnabled,
92
+ reverseSkillRepo: config.reverseSkillRepo,
93
+ reverseSkillRef: config.reverseSkillRef,
94
+ reverseSkillGroups: [...config.reverseSkillGroups],
95
+ installRoot: config.installRoot,
96
+ },
97
+ install: {
98
+ installed: runtime.install !== null,
99
+ version: runtime.install?.version ?? null,
100
+ root: paths.installRoot,
101
+ entry: runtime.install?.entry ?? null,
102
+ },
103
+ skill: { ...runtime.skill },
104
+ reverse: {
105
+ installed: runtime.reverse.installed,
106
+ repo: runtime.reverse.repo,
107
+ ref: runtime.reverse.ref,
108
+ sha: runtime.reverse.sha,
109
+ version: runtime.reverse.version,
110
+ root: reverse.root,
111
+ tree: reverse.tree,
112
+ files: runtime.reverse.files,
113
+ groups: [...runtime.reverse.groups],
114
+ activeGroups: activeDirs.map((dir) =>
115
+ dir.startsWith(`${reverse.tree}/`) ? dir.slice(reverse.tree.length + 1) : dir,
116
+ ),
117
+ updatedAt: runtime.reverse.updatedAt,
118
+ },
119
+ mcp: {
120
+ mode,
121
+ enabled: config.mcpEnabled && launch !== null,
122
+ command: launch?.command ?? null,
123
+ args: launch?.args ?? [],
124
+ },
125
+ paths: { ...paths, reverseRoot: reverse.root },
126
+ job: options.board.snapshot(),
127
+ }
128
+ }
129
+
130
+ /** 发送 JSON 响应。 */
131
+ function makeSend(): Send {
132
+ return (res, status, body) => {
133
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
134
+ res.end(JSON.stringify(body))
135
+ }
136
+ }
137
+
138
+ /** 异常兜底:日志 + 400(响应头已发则只收尾)。 */
139
+ function makeGuard(logger: RouteLogger, send: Send): (handler: RouteHandler) => RouteHandler {
140
+ return (handler) => async (req, res) => {
141
+ try {
142
+ await handler(req, res)
143
+ } catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error)
145
+ logger.warn(`${req.method ?? '?'} ${req.url ?? '?'} 失败:${message}`)
146
+ if (!res.headersSent) send(res, 400, { job: null, error: message } satisfies JobActionDto)
147
+ else res.end()
148
+ }
149
+ }
150
+ }
151
+
152
+ /** 方法门:不匹配即 405。 */
153
+ function withMethod(method: string, send: Send, handler: RouteHandler): RouteHandler {
154
+ return async (req, res) => {
155
+ if (req.method !== method) {
156
+ send(res, 405, { job: null, error: 'method not allowed' } satisfies JobActionDto)
157
+ return
158
+ }
159
+ await handler(req, res)
160
+ }
161
+ }
162
+
163
+ /** 起作业的处理器工厂(409 = 已有作业在跑)。 */
164
+ function startJobHandler(
165
+ kind: JobKind,
166
+ options: ReaRoutesOptions,
167
+ logger: RouteLogger,
168
+ send: Send,
169
+ ): RouteHandler {
170
+ return async (req, res) => {
171
+ const overrides = proxyPortOf(await readBody(req))
172
+ const operations = options.manager.operations(
173
+ overrides === undefined ? undefined : { proxyPort: overrides },
174
+ )
175
+ const job = options.board.start(kind, operations[kind])
176
+ if (job === null) {
177
+ send(res, 409, {
178
+ job: options.board.snapshot(),
179
+ error: '已有作业在运行',
180
+ } satisfies JobActionDto)
181
+ return
182
+ }
183
+ logger.info(`已启动 ${kind} 作业`)
184
+ send(res, 200, { job } satisfies JobActionDto)
185
+ }
186
+ }
187
+
188
+ /** 全部路由(path 末段 → 处理器)。 */
189
+ function buildRoutes(
190
+ options: ReaRoutesOptions,
191
+ logger: RouteLogger,
192
+ send: Send,
193
+ ): Record<string, RouteHandler> {
194
+ return {
195
+ status: withMethod('GET', send, async (_req, res) => {
196
+ send(res, 200, statusPayload(options))
197
+ }),
198
+ install: withMethod('POST', send, startJobHandler('install', options, logger, send)),
199
+ skill: withMethod('POST', send, startJobHandler('skill', options, logger, send)),
200
+ reverse: withMethod('POST', send, startJobHandler('reverse', options, logger, send)),
201
+ cancel: withMethod('POST', send, async (_req, res) => {
202
+ const cancelled = options.board.cancel()
203
+ send(res, 200, {
204
+ job: options.board.snapshot(),
205
+ ...(cancelled ? {} : { error: '没有正在运行的作业' }),
206
+ } satisfies JobActionDto)
207
+ }),
208
+ }
209
+ }
210
+
211
+ /** 注册全部路由。 */
212
+ export function registerReaRoutes(ctx: Context, options: ReaRoutesOptions): void {
213
+ const logger = ctx.logger('agent-preset-rea')
214
+ const send = makeSend()
215
+ const guard = makeGuard(logger, send)
216
+ for (const [name, handler] of Object.entries(buildRoutes(options, logger, send))) {
217
+ ctx.webServer.register({
218
+ kind: 'exact',
219
+ path: `${ROUTE_BASE}/${name}`,
220
+ handler: guard(handler),
221
+ })
222
+ }
223
+ }
package/src/skill.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
- * 预设作用域内的 skill 根解析:随包 REA 官方 skill + 配置追加目录。
2
+ * 预设作用域内的 skill 根解析:随包 REA 官方 skill(或手动更新副本)+ 配置追加目录。
3
3
  *
4
- * 两者都只加在本预设 `skill-filesystem` 行的 `customSkillDirs`(rank 300 的
5
- * **追加**根)上,因此不会进入项目根 / 用户根 / `$DSH_BUNDLED_SKILL_DIR`,
6
- * 其他预设的 skill 目录不受影响。
4
+ * 全部只加在本预设 `skill-filesystem` 行的 `customSkillDirs`(rank 300 的**追加**根)
5
+ * 上,因此不进入项目根 / 用户根 / `$DSH_BUNDLED_SKILL_DIR`,其他预设不受影响。
6
+ *
7
+ * 手动更新副本存在时**替代**随包根而不是并排:两者同名(`reverse-engineer-anything`),
8
+ * 并排只会得到「哪个生效」的歧义。
7
9
  * @module @dsh-plus/agent-preset-rea/skill
8
10
  */
9
11
  import { fileURLToPath } from 'node:url'
10
12
 
11
13
  import type { AgentPresetReaConfig } from './config.ts'
14
+ import type { RuntimeSnapshot } from './manager.ts'
15
+ import { reverseSkillDirs } from './reverse.ts'
12
16
 
13
17
  /**
14
18
  * 随包 REA 官方 skill 根(包内 `skills/`)。
@@ -18,7 +22,28 @@ import type { AgentPresetReaConfig } from './config.ts'
18
22
  */
19
23
  export const BUNDLED_SKILL_ROOT = fileURLToPath(new URL('../skills', import.meta.url))
20
24
 
21
- /** skill-filesystem 的追加根清单:随包 REA skill 在前,配置追加目录随后。 */
22
- export function reaSkillDirs(config: AgentPresetReaConfig): string[] {
23
- return [BUNDLED_SKILL_ROOT, ...config.extraSkillDirs]
25
+ /**
26
+ * REA skill 根(随包副本,或手动更新副本——后者存在即替代前者)。
27
+ * @param updatedRoot - 手动更新副本根(来自 manager 现探)。
28
+ */
29
+ export function reaSkillDirs(updatedRoot: string | null = null): string[] {
30
+ return updatedRoot === null ? [BUNDLED_SKILL_ROOT] : [updatedRoot]
31
+ }
32
+
33
+ /**
34
+ * 本预设 `skill-filesystem` 行的完整追加根清单,按三个开关裁剪:
35
+ * - `skillEnabled=false` → 不挂 REA 官方 skill(安装副本仍留在磁盘);
36
+ * - `reverseSkillEnabled=false` → 不挂 reverse-skill 的技能组;
37
+ * - `extraSkillDirs` 是部署方追加项,不受开关影响(关掉的是本插件自带的两个源)。
38
+ * @param config - 插件配置。
39
+ * @param runtime - 现探运行期事实。
40
+ */
41
+ export function presetSkillDirs(config: AgentPresetReaConfig, runtime: RuntimeSnapshot): string[] {
42
+ const rea =
43
+ config.skillEnabled === false
44
+ ? []
45
+ : reaSkillDirs(runtime.skill.source === 'updated' ? runtime.skill.root : null)
46
+ const reverse =
47
+ config.reverseSkillEnabled === false ? [] : reverseSkillDirs(config, runtime.reverse)
48
+ return [...rea, ...reverse, ...config.extraSkillDirs]
24
49
  }