@dsh-plus/agent-preset-rea 0.1.1 → 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/definition.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * 增量仅三处,测试按 {@link ADDED_ROWS} / {@link CONFIG_DELTAS} 白名单放行:
10
10
  * 1. `persona` 行 `prefix` 取配置值(默认空串 = 空 persona);
11
- * 2. `skill-filesystem` 行加 `customSkillDirs`(随包 REA skill 或手动更新副本 + 追加目录);
11
+ * 2. `skill-filesystem` 行加 `customSkillDirs`(REA skill 源与 reverse-skill 技能组,
12
+ * 各受一个活动开关控制,另加部署方 `extraSkillDirs`);
12
13
  * 3. 新增 `mcp-rea` 行:官方 MCP 客户端连 REA stdio 服务,落在本预设作用域内;
13
14
  * **未安装 REA 时该行 `disabled`**(不 spawn npx,见 mcp.ts 的说明)。
14
15
  *
@@ -24,7 +25,8 @@ import type { AgentPresetReaConfig } from './config.ts'
24
25
  import type { RuntimeSnapshot } from './manager.ts'
25
26
  import { DEFAULT_MCP_COMMAND, reaMcpArgs, reaMcpEnv, reaMcpLaunch } from './mcp.ts'
26
27
  import { PLAN_MODE_SECTION } from './prompt.ts'
27
- import { BUNDLED_SKILL_ROOT, reaSkillDirs } from './skill.ts'
28
+ import { emptyReverseState } from './reverse.ts'
29
+ import { BUNDLED_SKILL_ROOT, presetSkillDirs } from './skill.ts'
28
30
 
29
31
  /** 预设注册表身份(改 id 等于换预设,default 等引用都会落空)。 */
30
32
  export const REA_PRESET_ID = 'rea'
@@ -58,6 +60,7 @@ export function emptyRuntime(): RuntimeSnapshot {
58
60
  files: 0,
59
61
  updatedAt: null,
60
62
  },
63
+ reverse: emptyReverseState(),
61
64
  }
62
65
  }
63
66
 
@@ -102,10 +105,7 @@ function baseRows(config: AgentPresetReaConfig, runtime: RuntimeSnapshot): Row[]
102
105
  id: 'skill-filesystem',
103
106
  name: '@deepseek-ai/dsh-skill-filesystem',
104
107
  config: {
105
- customSkillDirs: reaSkillDirs(
106
- config,
107
- runtime.skill.source === 'updated' ? runtime.skill.root : null,
108
- ),
108
+ customSkillDirs: presetSkillDirs(config, runtime),
109
109
  },
110
110
  },
111
111
  { id: 'tool-skill', name: '@deepseek-ai/dsh-tool-skill' },
package/src/dto.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * @module @dsh-plus/agent-preset-rea/dto
7
7
  */
8
8
 
9
- /** 作业种类。 */
10
- export type JobKind = 'install' | 'skill'
9
+ /** 作业种类:REA 包安装/更新、REA skill 更新、reverse-skill 更新。 */
10
+ export type JobKind = 'install' | 'skill' | 'reverse'
11
11
 
12
12
  /** 作业状态。 */
13
13
  export type JobState = 'running' | 'ok' | 'error' | 'cancelled'
@@ -64,18 +64,44 @@ export interface McpDto {
64
64
  args: string[]
65
65
  }
66
66
 
67
+ /** GitHub 技能源(zhaoxuya520/reverse-skill)事实。 */
68
+ export interface ReverseSkillDto {
69
+ installed: boolean
70
+ repo: string
71
+ /** 清单记录的 ref(空串 = 安装时取的默认分支)。 */
72
+ ref: string | null
73
+ /** 提交 sha(内容级溯源)。 */
74
+ sha: string | null
75
+ /** 仓库 VERSION 文件内容。 */
76
+ version: string | null
77
+ root: string
78
+ tree: string
79
+ files: number
80
+ /** 树内发现的全部技能组(相对安装树根)。 */
81
+ groups: string[]
82
+ /** 配置选中且实际挂进预设的组。 */
83
+ activeGroups: string[]
84
+ updatedAt: string | null
85
+ }
86
+
67
87
  /** `GET /status` 响应。 */
68
88
  export interface StatusDto {
69
89
  config: {
70
90
  reaVersion: string
71
91
  proxyPort: number
72
92
  mcpEnabled: boolean
93
+ skillEnabled: boolean
94
+ reverseSkillEnabled: boolean
95
+ reverseSkillRepo: string
96
+ reverseSkillRef: string
97
+ reverseSkillGroups: string[]
73
98
  installRoot: string
74
99
  }
75
100
  install: InstallDto
76
101
  skill: SkillDto
102
+ reverse: ReverseSkillDto
77
103
  mcp: McpDto
78
- paths: { installRoot: string; skillRoot: string }
104
+ paths: { installRoot: string; skillRoot: string; reverseRoot: string }
79
105
  job: JobDto | null
80
106
  }
81
107
 
package/src/exec.ts CHANGED
@@ -53,6 +53,13 @@ export interface JobOperation {
53
53
  run(ctx: JobTaskContext): Promise<string>
54
54
  }
55
55
 
56
+ /** 作业依赖:生效配置、命令执行面、作业完成后的现探刷新。 */
57
+ export interface JobDeps {
58
+ config: AgentPresetReaConfig
59
+ runner: CommandRunner
60
+ refresh(): void
61
+ }
62
+
56
63
  /** 实际 runner:spawn 子进程、逐行回调、AbortSignal 触发即杀。 */
57
64
  export const spawnRunner: CommandRunner = {
58
65
  run: (command, args, options) =>
@@ -90,32 +97,47 @@ export const spawnRunner: CommandRunner = {
90
97
  }),
91
98
  }
92
99
 
100
+ /** 本机代理 URL(未配置代理时为 null)。 */
101
+ export function proxyUrl(config: AgentPresetReaConfig): string | null {
102
+ return config.proxyPort > 0 ? `http://127.0.0.1:${config.proxyPort}` : null
103
+ }
104
+
93
105
  /**
94
- * npm 子进程环境:继承宿主环境,附带代理端口(port > 0 时同时给 npm 与
95
- * prebuild-install 注入代理——后者只认 `https_proxy` 系列环境变量,
96
- * 这是原生依赖 prebuild 下载卡死的正解)。
106
+ * 子进程环境:继承宿主环境,附带代理端口(port > 0 时给 `https_proxy` 系列,
107
+ * prebuild-install 与 curl 都认这些变量——原生依赖 prebuild 下载卡死的正解)。
108
+ * @param config - 插件配置。
109
+ * @param npm - 是否附带 npm 专属开关(npm 子进程用;curl 等非 npm 命令不需要)。
97
110
  */
98
- export function npmEnv(config: AgentPresetReaConfig): Record<string, string> {
111
+ export function commandEnv(config: AgentPresetReaConfig, npm = false): Record<string, string> {
99
112
  const env: Record<string, string> = {}
100
113
  for (const [key, value] of Object.entries(process.env)) {
101
114
  if (value !== undefined) env[key] = value
102
115
  }
103
- env['npm_config_audit'] = 'false'
104
- env['npm_config_fund'] = 'false'
105
- env['npm_config_update_notifier'] = 'false'
106
- env['npm_config_loglevel'] = 'http'
107
- if (config.proxyPort > 0) {
108
- const proxy = `http://127.0.0.1:${config.proxyPort}`
116
+ if (npm) {
117
+ env['npm_config_audit'] = 'false'
118
+ env['npm_config_fund'] = 'false'
119
+ env['npm_config_update_notifier'] = 'false'
120
+ env['npm_config_loglevel'] = 'http'
121
+ }
122
+ const proxy = proxyUrl(config)
123
+ if (proxy !== null) {
109
124
  env['HTTP_PROXY'] = proxy
110
125
  env['HTTPS_PROXY'] = proxy
111
126
  env['http_proxy'] = proxy
112
127
  env['https_proxy'] = proxy
113
- env['npm_config_proxy'] = proxy
114
- env['npm_config_https_proxy'] = proxy
128
+ if (npm) {
129
+ env['npm_config_proxy'] = proxy
130
+ env['npm_config_https_proxy'] = proxy
131
+ }
115
132
  }
116
133
  return env
117
134
  }
118
135
 
136
+ /** npm 子进程环境({@link commandEnv} 的 npm 形态)。 */
137
+ export function npmEnv(config: AgentPresetReaConfig): Record<string, string> {
138
+ return commandEnv(config, true)
139
+ }
140
+
119
141
  /** 版本收窄:进命令前把配置里的钉版校验成纯版本号。 */
120
142
  export function safeVersion(version: string): string {
121
143
  const trimmed = version.trim()
package/src/index.ts CHANGED
@@ -18,6 +18,11 @@
18
18
  * 本插件据此换代注册(注销旧声明 → 按新值重建声明),**新建会话**即用新提示词,
19
19
  * 已存在会话保持既有 composition(注册表语义:运行中会话的装配不随换代改变)。
20
20
  *
21
+ * 卡片上还有三个能力开关(活动字段:REA MCP 行、REA skill、reverse-skill 技能包)
22
+ * 与两个受管技能源的手动更新:REA 官方 skill(npm 包内 `skills/`)与 GitHub 的
23
+ * `zhaoxuya520/reverse-skill`(路由包,按技能组挂进 `customSkillDirs`)。开关只管
24
+ * 挂不挂,安装副本始终留在插件数据目录。
25
+ *
21
26
  * REA 的安装与更新也在卡片上(受管安装 + 后台作业 + 实时进度 + 可选代理端口):
22
27
  * 宿主半的 manager 把 `rea-agents` 装进插件数据目录、把 skill 拉成可替换副本,
23
28
  * 作业完成后同样换代注册——因此预设声明里**不会**在 boot 期 spawn `npx`
@@ -69,6 +74,7 @@ export type {
69
74
  JobProgressDto,
70
75
  JobState,
71
76
  McpDto,
77
+ ReverseSkillDto,
72
78
  SkillDto,
73
79
  StatusDto,
74
80
  } from './dto.ts'
@@ -118,8 +124,22 @@ export {
118
124
  } from './mcp.ts'
119
125
  export { PLAN_MODE_SECTION } from './prompt.ts'
120
126
  export { createPresetRegistrar, type PresetRegistrar, type Unregister } from './registration.ts'
127
+ export {
128
+ discoverSkillGroups,
129
+ probeReverse,
130
+ REVERSE_DEFAULT_REPO,
131
+ REVERSE_DIR,
132
+ REVERSE_PHASES,
133
+ REVERSE_TREE_DIR,
134
+ type ReverseManifest,
135
+ type ReversePaths,
136
+ type ReverseState,
137
+ reversePaths,
138
+ reverseSkillDirs,
139
+ runReverseSkillJob,
140
+ } from './reverse.ts'
121
141
  export { ROUTE_BASE, registerReaRoutes, statusPayload } from './routes.ts'
122
- export { BUNDLED_SKILL_ROOT, reaSkillDirs } from './skill.ts'
142
+ export { BUNDLED_SKILL_ROOT, presetSkillDirs, reaSkillDirs } from './skill.ts'
123
143
  export type { AgentPresetReaConfig, AgentPresetReaConfigFields }
124
144
  export { Config }
125
145
 
package/src/manager.ts CHANGED
@@ -30,6 +30,7 @@ import type { AgentPresetReaConfig } from './config.ts'
30
30
  import {
31
31
  type CommandRunner,
32
32
  countFiles,
33
+ type JobDeps,
33
34
  type JobOperation,
34
35
  type JobTaskContext,
35
36
  lineSink,
@@ -39,6 +40,9 @@ import {
39
40
  safeVersion,
40
41
  spawnRunner,
41
42
  } from './exec.ts'
43
+ import { probeReverse, REVERSE_PHASES, type ReverseState, runReverseSkillJob } from './reverse.ts'
44
+
45
+ export type { JobDeps }
42
46
 
43
47
  /** npm 包名。 */
44
48
  export const REA_PACKAGE = 'rea-agents'
@@ -80,6 +84,8 @@ export interface SkillState {
80
84
  export interface RuntimeSnapshot {
81
85
  install: ReaInstall | null
82
86
  skill: SkillState
87
+ /** GitHub 技能源(zhaoxuya520/reverse-skill)现状。 */
88
+ reverse: ReverseState
83
89
  }
84
90
 
85
91
  /** skill 清单(与随包夹具同形;更新副本多写入 updatedAt)。 */
@@ -107,8 +113,12 @@ export interface ReaManager {
107
113
  snapshot(): RuntimeSnapshot
108
114
  /** 重新现探并更新快照(作业完成、volatile 变更后调用)。 */
109
115
  refresh(): RuntimeSnapshot
110
- /** 受管作业(安装/更新 REA 包、更新 skill)。 */
111
- operations(overrides?: JobOverrides): { install: JobOperation; skill: JobOperation }
116
+ /** 受管作业(安装/更新 REA 包、更新 REA skill、更新 reverse-skill)。 */
117
+ operations(overrides?: JobOverrides): {
118
+ install: JobOperation
119
+ skill: JobOperation
120
+ reverse: JobOperation
121
+ }
112
122
  }
113
123
 
114
124
  /** 安装作业阶段名。 */
@@ -219,16 +229,10 @@ export function probeRuntime(
219
229
  return {
220
230
  install: probeInstall(paths),
221
231
  skill: probeSkill(paths, bundledRoot, bundledVersion),
232
+ reverse: probeReverse(config),
222
233
  }
223
234
  }
224
235
 
225
- /** 作业依赖:生效配置、命令执行面、作业完成后的现探刷新。 */
226
- export interface JobDeps {
227
- config: AgentPresetReaConfig
228
- runner: CommandRunner
229
- refresh(): void
230
- }
231
-
232
236
  /**
233
237
  * 解析钉版在 registry 上的实际版本:顺带做存在性校验(失败即报错,
234
238
  * 而不是装出一棵空树让 MCP 行指向不存在的入口)。
@@ -413,6 +417,7 @@ export function createManager(options: {
413
417
  return {
414
418
  install: { phases: INSTALL_PHASES, run: (ctx) => runInstallJob(deps, ctx) },
415
419
  skill: { phases: SKILL_PHASES, run: (ctx) => runSkillJob(deps, ctx) },
420
+ reverse: { phases: REVERSE_PHASES, run: (ctx) => runReverseSkillJob(deps, ctx) },
416
421
  }
417
422
  },
418
423
  }
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
+ }