@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/manager.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * REA 的受管安装与状态探测:把「REA 包」与「REA skill」都变成插件自己管理的本机资源,
3
+ * 从而摆脱启动期 `npx -y` 拉取(那会在 boot 里跑 npm 安装脚本:原生依赖
4
+ * `@lydell/node-pty` 的 prebuild 下载一旦卡住,预设激活就不返回,名册与插件页
5
+ * 一起被吊住;事故经过见 docs/README.md「启动期拉取的教训」)。
6
+ *
7
+ * 落点(属插件数据,不进 settings,见 @dsh-plus/shared/plugin-data;插件数据目录名
8
+ * 取插件 id `dsh-plus-agent-preset-rea`):
9
+ * - REA 包:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/rea-agent/`(npm --prefix
10
+ * 安装树;入口 `node_modules/rea-agents/scripts/rea.mjs`,用 `process.execPath` 直接
11
+ * spawn,跨平台不经 shell shim);
12
+ * - 手动更新的 skill:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/skills/`
13
+ * (根下是 `<技能名>/` + `manifest.json`,与随包 `skills/` 同构;
14
+ * 清单完整时替代随包 skill 根)。
15
+ *
16
+ * 作业都是「先落 staging 再换目录」:中断只留下 staging,不会让半截安装树被当成可用
17
+ * 安装;换目录后清理旧树,运行中的旧进程按 POSIX 语义继续持有已打开文件。
18
+ *
19
+ * 外部进程经 {@link CommandRunner} 注入(见 exec.ts),单测用假 runner 跑完整状态机。
20
+ * @module @dsh-plus/agent-preset-rea/manager
21
+ */
22
+ import { createHash } from 'node:crypto'
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
24
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
25
+ import { join } from 'node:path'
26
+
27
+ import { pluginDataPath } from '@dsh-plus/shared'
28
+
29
+ import type { AgentPresetReaConfig } from './config.ts'
30
+ import {
31
+ type CommandRunner,
32
+ countFiles,
33
+ type JobDeps,
34
+ type JobOperation,
35
+ type JobTaskContext,
36
+ lineSink,
37
+ listFiles,
38
+ NPM,
39
+ npmEnv,
40
+ safeVersion,
41
+ spawnRunner,
42
+ } from './exec.ts'
43
+ import { probeReverse, REVERSE_PHASES, type ReverseState, runReverseSkillJob } from './reverse.ts'
44
+
45
+ export type { JobDeps }
46
+
47
+ /** npm 包名。 */
48
+ export const REA_PACKAGE = 'rea-agents'
49
+
50
+ /** 受管安装树目录名。 */
51
+ export const REA_INSTALL_DIR = 'rea-agent'
52
+
53
+ /** 手动更新 skill 的目录名。 */
54
+ export const REA_SKILL_DIR = 'skills'
55
+
56
+ /** REA 随包 skill 名(tar 包内路径与目录名同此)。 */
57
+ export const REA_SKILL_NAME = 'reverse-engineer-anything'
58
+
59
+ /** 插件数据目录标识(`<DSH_HOME>/dsh-plus/<id>`)。 */
60
+ const PLUGIN_ID = 'dsh-plus-agent-preset-rea'
61
+
62
+ /** 已安装的 REA 事实。 */
63
+ export interface ReaInstall {
64
+ version: string
65
+ root: string
66
+ /** spawn 用的可执行文件(`process.execPath`,即当前 node)。 */
67
+ command: string
68
+ /** 入口脚本绝对路径(`scripts/rea.mjs`)。 */
69
+ entry: string
70
+ }
71
+
72
+ /** skill 来源事实。 */
73
+ export interface SkillState {
74
+ /** `bundled` = 随包副本;`updated` = 插件数据目录里的手动更新副本。 */
75
+ source: 'bundled' | 'updated'
76
+ /** 来源版本(从清单 source 解析;解析不出为 null)。 */
77
+ version: string | null
78
+ root: string
79
+ files: number
80
+ updatedAt: string | null
81
+ }
82
+
83
+ /** 运行期事实快照(现探,不进配置;声明组装按它决定 mcp 行与 skill 根)。 */
84
+ export interface RuntimeSnapshot {
85
+ install: ReaInstall | null
86
+ skill: SkillState
87
+ /** GitHub 技能源(zhaoxuya520/reverse-skill)现状。 */
88
+ reverse: ReverseState
89
+ }
90
+
91
+ /** skill 清单(与随包夹具同形;更新副本多写入 updatedAt)。 */
92
+ export interface SkillManifest {
93
+ source: string
94
+ files: { path: string; sha256: string; bytes: number }[]
95
+ updatedAt?: string
96
+ }
97
+
98
+ /** 受管路径。 */
99
+ export interface ManagerPaths {
100
+ installRoot: string
101
+ skillRoot: string
102
+ }
103
+
104
+ /** 作业可一次性覆盖的配置项(卡片未保存的代理端口即时生效用)。 */
105
+ export interface JobOverrides {
106
+ proxyPort?: number
107
+ }
108
+
109
+ /** REA 管理面。 */
110
+ export interface ReaManager {
111
+ paths(): ManagerPaths
112
+ /** 现探运行期事实(同步;声明组装在 `apply` 期就要读到)。 */
113
+ snapshot(): RuntimeSnapshot
114
+ /** 重新现探并更新快照(作业完成、volatile 变更后调用)。 */
115
+ refresh(): RuntimeSnapshot
116
+ /** 受管作业(安装/更新 REA 包、更新 REA skill、更新 reverse-skill)。 */
117
+ operations(overrides?: JobOverrides): {
118
+ install: JobOperation
119
+ skill: JobOperation
120
+ reverse: JobOperation
121
+ }
122
+ }
123
+
124
+ /** 安装作业阶段名。 */
125
+ export const INSTALL_PHASES: string[] = [
126
+ '解析版本信息',
127
+ `下载并安装 ${REA_PACKAGE}`,
128
+ '替换安装目录',
129
+ '校验安装',
130
+ ]
131
+
132
+ /** skill 作业阶段名。 */
133
+ export const SKILL_PHASES: string[] = [
134
+ '解析版本信息',
135
+ '下载 skill 包',
136
+ '解包 skills',
137
+ '校验并写入清单',
138
+ ]
139
+
140
+ /**
141
+ * 解析受管路径:`installRoot` 非空即运维指定位置(部署方平面),
142
+ * 否则落插件数据目录;skill 更新副本固定落插件数据目录。
143
+ */
144
+ export function managerPaths(config: AgentPresetReaConfig): ManagerPaths {
145
+ const root = config.installRoot.trim()
146
+ return {
147
+ installRoot: root === '' ? pluginDataPath(PLUGIN_ID, REA_INSTALL_DIR) : root,
148
+ skillRoot: pluginDataPath(PLUGIN_ID, REA_SKILL_DIR),
149
+ }
150
+ }
151
+
152
+ /** 读 JSON(不存在或坏内容返回 null)。 */
153
+ function readJson(file: string): unknown {
154
+ try {
155
+ return JSON.parse(readFileSync(file, 'utf8')) as unknown
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+
161
+ /** 记录型守卫。 */
162
+ function asRecord(value: unknown): Record<string, unknown> | null {
163
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
164
+ }
165
+
166
+ /** 安装树内固定入口:`node_modules/rea-agents/scripts/rea.mjs`。 */
167
+ function reaEntryFile(root: string): string {
168
+ return join(root, 'node_modules', REA_PACKAGE, 'scripts', 'rea.mjs')
169
+ }
170
+
171
+ /** 从清单 source(`rea-agents@1.2.3`)解析版本。 */
172
+ function versionOfSource(source: string): string | null {
173
+ const prefix = `${REA_PACKAGE}@`
174
+ return source.startsWith(prefix) ? source.slice(prefix.length) : null
175
+ }
176
+
177
+ /**
178
+ * 探测已安装 REA:包版本可读 **且** 入口脚本存在才算安装完整
179
+ * (只有一半的安装树、或换目录中断留下的 staging 都不算)。
180
+ */
181
+ export function probeInstall(paths: ManagerPaths): ReaInstall | null {
182
+ const pkg = asRecord(
183
+ readJson(join(paths.installRoot, 'node_modules', REA_PACKAGE, 'package.json')),
184
+ )
185
+ const version = typeof pkg?.['version'] === 'string' ? pkg['version'] : null
186
+ const entry = reaEntryFile(paths.installRoot)
187
+ if (version === null || !existsSync(entry)) return null
188
+ return { version, root: paths.installRoot, command: process.execPath, entry }
189
+ }
190
+
191
+ /** 探测 skill 来源:数据目录里清单完整且技能文件在位才认更新副本。 */
192
+ export function probeSkill(
193
+ paths: ManagerPaths,
194
+ bundledRoot: string,
195
+ bundledVersion: string,
196
+ ): SkillState {
197
+ const manifest = asRecord(readJson(join(paths.skillRoot, 'manifest.json')))
198
+ const source = typeof manifest?.['source'] === 'string' ? manifest['source'] : null
199
+ const files = Array.isArray(manifest?.['files']) ? manifest['files'].length : 0
200
+ if (
201
+ source !== null &&
202
+ files > 0 &&
203
+ existsSync(join(paths.skillRoot, REA_SKILL_NAME, 'SKILL.md'))
204
+ ) {
205
+ return {
206
+ source: 'updated',
207
+ version: versionOfSource(source),
208
+ root: paths.skillRoot,
209
+ files,
210
+ updatedAt: typeof manifest?.['updatedAt'] === 'string' ? manifest['updatedAt'] : null,
211
+ }
212
+ }
213
+ return {
214
+ source: 'bundled',
215
+ version: bundledVersion,
216
+ root: bundledRoot,
217
+ files: countFiles(bundledRoot),
218
+ updatedAt: null,
219
+ }
220
+ }
221
+
222
+ /** 现探运行期事实。 */
223
+ export function probeRuntime(
224
+ config: AgentPresetReaConfig,
225
+ bundledRoot: string,
226
+ bundledVersion: string,
227
+ ): RuntimeSnapshot {
228
+ const paths = managerPaths(config)
229
+ return {
230
+ install: probeInstall(paths),
231
+ skill: probeSkill(paths, bundledRoot, bundledVersion),
232
+ reverse: probeReverse(config),
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 解析钉版在 registry 上的实际版本:顺带做存在性校验(失败即报错,
238
+ * 而不是装出一棵空树让 MCP 行指向不存在的入口)。
239
+ */
240
+ async function resolveVersion(deps: JobDeps, ctx: JobTaskContext, wanted: string): Promise<void> {
241
+ ctx.phase(0)
242
+ const lines: string[] = []
243
+ const result = await deps.runner.run(
244
+ NPM,
245
+ ['view', `${REA_PACKAGE}@${wanted}`, 'version', '--json'],
246
+ {
247
+ env: npmEnv(deps.config),
248
+ signal: ctx.signal,
249
+ onLine: (line) => {
250
+ lines.push(line)
251
+ lineSink(ctx)(line)
252
+ },
253
+ },
254
+ )
255
+ if (result.code !== 0) throw new Error(`registry 上找不到 ${REA_PACKAGE}@${wanted}`)
256
+ const reported = lines.join('').replace(/["\s]/g, '')
257
+ if (reported !== '' && reported !== wanted) {
258
+ ctx.log(`registry 返回 ${reported}(配置钉的是 ${wanted})`)
259
+ }
260
+ }
261
+
262
+ /** 装到 staging 并清掉失败现场的公共包壳。 */
263
+ async function npmInstall(deps: JobDeps, ctx: JobTaskContext, staging: string): Promise<void> {
264
+ const version = safeVersion(deps.config.reaVersion)
265
+ await rm(staging, { recursive: true, force: true })
266
+ const result = await deps.runner.run(
267
+ NPM,
268
+ [
269
+ 'install',
270
+ '--prefix',
271
+ staging,
272
+ '--no-audit',
273
+ '--no-fund',
274
+ '--loglevel',
275
+ 'http',
276
+ `${REA_PACKAGE}@${version}`,
277
+ ],
278
+ { env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
279
+ )
280
+ if (result.code !== 0) {
281
+ await rm(staging, { recursive: true, force: true })
282
+ throw new Error(`npm install 失败(退出码 ${result.code})`)
283
+ }
284
+ }
285
+
286
+ /** 安装/更新 REA 包:staging 安装 → 换目录 → 校验入口可读。 */
287
+ export async function runInstallJob(deps: JobDeps, ctx: JobTaskContext): Promise<string> {
288
+ const version = safeVersion(deps.config.reaVersion)
289
+ const root = managerPaths(deps.config).installRoot
290
+ const staging = `${root}.staging`
291
+ await resolveVersion(deps, ctx, version)
292
+
293
+ ctx.phase(1)
294
+ await npmInstall(deps, ctx, staging)
295
+
296
+ ctx.phase(2)
297
+ const backup = `${root}.old`
298
+ await rm(backup, { recursive: true, force: true })
299
+ if (existsSync(root)) await rename(root, backup)
300
+ await rename(staging, root)
301
+ await rm(backup, { recursive: true, force: true })
302
+
303
+ ctx.phase(3)
304
+ const probe = probeInstall(managerPaths(deps.config))
305
+ if (probe === null) throw new Error('安装校验失败:入口脚本不可用')
306
+ deps.refresh()
307
+ ctx.log(`已安装 ${REA_PACKAGE}@${probe.version} → ${probe.root}`)
308
+ return probe.version
309
+ }
310
+
311
+ /** 逐文件 sha256(相对路径 + 字节数)。 */
312
+ function hashFiles(dir: string): SkillManifest['files'] {
313
+ return listFiles(dir).map((path) => {
314
+ const bytes = readFileSync(join(dir, path))
315
+ return { path, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length }
316
+ })
317
+ }
318
+
319
+ /** 从 tar 包取钉版 skill 目录(副本根与随包 `skills/` 同构:只 strip 两层)。 */
320
+ async function unpackSkill(
321
+ deps: JobDeps,
322
+ ctx: JobTaskContext,
323
+ staging: string,
324
+ tree: string,
325
+ ): Promise<void> {
326
+ const version = safeVersion(deps.config.reaVersion)
327
+ await rm(staging, { recursive: true, force: true })
328
+ await mkdir(staging, { recursive: true })
329
+ const packed = await deps.runner.run(
330
+ NPM,
331
+ ['pack', `${REA_PACKAGE}@${version}`, '--pack-destination', staging, '--loglevel', 'http'],
332
+ { env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
333
+ )
334
+ if (packed.code !== 0) {
335
+ await rm(staging, { recursive: true, force: true })
336
+ throw new Error(`npm pack 失败(退出码 ${packed.code})`)
337
+ }
338
+ const tarball = readdirSync(staging).find((name) => name.endsWith('.tgz'))
339
+ if (tarball === undefined) throw new Error('npm pack 未产出 tarball')
340
+
341
+ ctx.phase(2)
342
+ await mkdir(tree, { recursive: true })
343
+ const untar = await deps.runner.run(
344
+ 'tar',
345
+ [
346
+ '-xzf',
347
+ join(staging, tarball),
348
+ '-C',
349
+ tree,
350
+ '--strip-components=2',
351
+ `package/skills/${REA_SKILL_NAME}`,
352
+ ],
353
+ { env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
354
+ )
355
+ if (untar.code !== 0) {
356
+ await rm(staging, { recursive: true, force: true })
357
+ throw new Error(`tar 解包失败(退出码 ${untar.code})`)
358
+ }
359
+ }
360
+
361
+ /** 拉取钉版 tarball 并替换手动更新的 skill 副本(写 sha256 清单后换目录)。 */
362
+ export async function runSkillJob(deps: JobDeps, ctx: JobTaskContext): Promise<string> {
363
+ const version = safeVersion(deps.config.reaVersion)
364
+ const root = managerPaths(deps.config).skillRoot
365
+ const staging = `${root}.staging`
366
+ const tree = join(staging, 'tree')
367
+ await resolveVersion(deps, ctx, version)
368
+ ctx.phase(1)
369
+ await unpackSkill(deps, ctx, staging, tree)
370
+
371
+ ctx.phase(3)
372
+ const files = hashFiles(tree)
373
+ if (files.length === 0) throw new Error('解包结果为空:上游 skill 目录结构可能已变')
374
+ const manifest: SkillManifest = {
375
+ source: `${REA_PACKAGE}@${version}`,
376
+ files,
377
+ updatedAt: new Date().toISOString(),
378
+ }
379
+ await writeFile(join(tree, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
380
+ await rm(root, { recursive: true, force: true })
381
+ await rename(tree, root)
382
+ await rm(staging, { recursive: true, force: true })
383
+ deps.refresh()
384
+ ctx.log(`已更新 skill ${REA_SKILL_NAME}(${files.length} 文件)→ ${root}`)
385
+ return version
386
+ }
387
+
388
+ /** 生效配置:作业可带一次性覆盖(卡片未保存的代理端口即时可用)。 */
389
+ export function configWith(
390
+ base: AgentPresetReaConfig,
391
+ overrides?: JobOverrides,
392
+ ): AgentPresetReaConfig {
393
+ if (overrides?.proxyPort === undefined) return base
394
+ return { ...base, proxyPort: overrides.proxyPort }
395
+ }
396
+
397
+ /** 建一个 REA 管理器。 */
398
+ export function createManager(options: {
399
+ config: () => AgentPresetReaConfig
400
+ bundledRoot: string
401
+ bundledVersion: string
402
+ runner?: CommandRunner
403
+ }): ReaManager {
404
+ const runner = options.runner ?? spawnRunner
405
+ const paths = (): ManagerPaths => managerPaths(options.config())
406
+ let cache: RuntimeSnapshot | null = null
407
+ const refresh = (): RuntimeSnapshot => {
408
+ cache = probeRuntime(options.config(), options.bundledRoot, options.bundledVersion)
409
+ return cache
410
+ }
411
+ return {
412
+ paths,
413
+ refresh,
414
+ snapshot: () => cache ?? refresh(),
415
+ operations: (overrides) => {
416
+ const deps: JobDeps = { config: configWith(options.config(), overrides), runner, refresh }
417
+ return {
418
+ install: { phases: INSTALL_PHASES, run: (ctx) => runInstallJob(deps, ctx) },
419
+ skill: { phases: SKILL_PHASES, run: (ctx) => runSkillJob(deps, ctx) },
420
+ reverse: { phases: REVERSE_PHASES, run: (ctx) => runReverseSkillJob(deps, ctx) },
421
+ }
422
+ },
423
+ }
424
+ }
package/src/mcp.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
- * REA MCP 行的取值派生:钉版版本、stdio 启动命令与参数、入参 schema 档位。
2
+ * REA MCP 行的取值派生:启动方式(受管安装入口 or 显式命令)、参数、入参/出参 schema 档位。
3
3
  *
4
4
  * REA 的 stdio 入口是 `rea-agents mcp`(`server.json` 的 positional 参数),
5
5
  * 包版本钉在 {@link REA_PINNED_VERSION}——升级只改这一个常量,漂移由测试拦住。
6
6
  * REA 的 Node 引擎要求为 `^22.19 || ^24.11 || >=26`(见 REA `docs/installation.md`)。
7
+ *
8
+ * **默认不 spawn npx**:启动期 `npx -y` 会跑 npm 安装脚本(原生依赖 prebuild 下载),
9
+ * 一旦卡住就把预设激活与整棵名册一起吊住;因此默认只认受管安装入口(见 manager.ts),
10
+ * 未安装时该行 `disabled`,安装入口在插件页卡片上。`mcpCommand` 显式配置时按用户
11
+ * 命令启动(经典 npx 配方仍可用,风险自负)。
7
12
  * @module @dsh-plus/agent-preset-rea/mcp
8
13
  */
9
14
  import type { AgentPresetReaConfig } from './config.ts'
@@ -11,7 +16,7 @@ import type { AgentPresetReaConfig } from './config.ts'
11
16
  /** 钉版基线:`rea-agents@6.5.0`(升级时同步测试断言、tests 清单与包文档)。 */
12
17
  export const REA_PINNED_VERSION = '6.5.0'
13
18
 
14
- /** stdio 启动命令默认值:POSIX 用 `npx`;Windows 需 `cmd.exe /c npx`(见包文档)。 */
19
+ /** 显式命令配 npx 时的参数配方基准值(`mcpArgs` 留空即按它派生)。 */
15
20
  export const DEFAULT_MCP_COMMAND = 'npx'
16
21
 
17
22
  /** REA 工具入参 schema 广告档位:`compact` 压请求前缀体积,`full` 与上游默认一致。 */
@@ -20,11 +25,47 @@ export const INPUT_SCHEMA_PROFILES = ['compact', 'full'] as const
20
25
  /** REA 工具出参 schema 广告档位:`off` 不下发(结果仍由 REA 侧校验),`full` 与上游默认一致。 */
21
26
  export const OUTPUT_SCHEMA_PROFILES = ['off', 'full'] as const
22
27
 
23
- /** stdio 参数:显式配置优先(Windows/全局安装配方),否则按钉版版本派生。 */
28
+ /** 受管安装的入口事实(结构上由 manager 的 ReaInstall 满足)。 */
29
+ export interface InstalledEntry {
30
+ /** spawn 用的可执行文件。 */
31
+ command: string
32
+ /** 入口脚本绝对路径。 */
33
+ entry: string
34
+ }
35
+
36
+ /** MCP 行的启动方式。 */
37
+ export interface McpLaunch {
38
+ command: string
39
+ args: string[]
40
+ }
41
+
42
+ /**
43
+ * 显式命令(`mcpCommand` 非空)时的参数:`mcpArgs` 优先;
44
+ * 留空时 npx 走 `-y rea-agents@<钉版> mcp`,其他可执行文件只补 `mcp`。
45
+ */
24
46
  export function reaMcpArgs(config: AgentPresetReaConfig): string[] {
25
- return config.mcpArgs.length > 0
26
- ? [...config.mcpArgs]
27
- : ['-y', `rea-agents@${config.reaVersion}`, 'mcp']
47
+ if (config.mcpArgs.length > 0) return [...config.mcpArgs]
48
+ return config.mcpCommand === DEFAULT_MCP_COMMAND
49
+ ? ['-y', `rea-agents@${config.reaVersion}`, 'mcp']
50
+ : ['mcp']
51
+ }
52
+
53
+ /**
54
+ * 本预设 MCP 行的启动方式:
55
+ * 1. `mcpCommand` 显式配置 → 用用户命令(Windows 配方、全局安装场景);
56
+ * 2. 受管安装存在 → 用当前 node 直接跑安装树里的入口脚本(跨平台、不经 shell shim);
57
+ * 3. 都没有 → `null`,调用方把该行置 `disabled`(未安装,等卡片上的安装动作)。
58
+ */
59
+ export function reaMcpLaunch(
60
+ config: AgentPresetReaConfig,
61
+ installed: InstalledEntry | null,
62
+ ): McpLaunch | null {
63
+ if (config.mcpCommand.trim() !== '') {
64
+ return { command: config.mcpCommand, args: reaMcpArgs(config) }
65
+ }
66
+ if (installed === null) return null
67
+ const extra = config.mcpArgs.length > 0 ? [...config.mcpArgs] : ['mcp']
68
+ return { command: installed.command, args: [installed.entry, ...extra] }
28
69
  }
29
70
 
30
71
  /**