dsh-claude-move 0.2.1

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.
@@ -0,0 +1,451 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/sources/codex/parser.mjs — Codex CLI 源解析器(四合一迁移向导,零 DSH 依赖)。
3
+ //
4
+ // 数据根定位($CODEX_HOME / ~/.codex)→ 只读白名单 → 结构化扫描:
5
+ // - sessions/**/rollout-*.jsonl:流式逐行读(envelope {timestamp,type,payload}),
6
+ // 产出会话条目(标题/消息/工具调用/畸形行计数),不整文件进内存;
7
+ // - skills/<name>/SKILL.md:classifySkill 判定兼容性(.system/README/MEMORY 跳过);
8
+ // - memories/*.md:记忆条目;
9
+ // - AGENTS.md / CODEX.md:全局指令;
10
+ // - hooks/*/command.md:命令分类(纯提示词/含 shell);hooks/*/prompt.md 与
11
+ // config.toml [commands]:钩子(不支持清单输入)。
12
+ // 凭据与内部状态(auth.json、state_*.sqlite、logs_*.sqlite、history.jsonl、log/、
13
+ // .tmp/、cache/、tmp/、shell_snapshots/、version.json)永不在白名单内。
14
+
15
+ import path from 'node:path'
16
+ import { homedir } from 'node:os'
17
+ import { access, readFile, readdir } from 'node:fs/promises'
18
+ import { createReadStream } from 'node:fs'
19
+ import { createInterface } from 'node:readline'
20
+ import {
21
+ assertAllowedRead,
22
+ digestText,
23
+ emptyDetection,
24
+ recordError,
25
+ truncateText,
26
+ } from '../contract.mjs'
27
+ import { classifySkill, skipSkillEntry } from '../../skill-migrate.mjs'
28
+ import { classifyCommand } from '../../commands-migrate.mjs'
29
+
30
+ export const source = 'codex'
31
+
32
+ // rollout JSONL 文件名(sessions/**/rollout-*.jsonl)。
33
+ const ROLLOUT_FILE_RE = /^rollout-.*\.jsonl$/
34
+
35
+ /** Codex 数据根定位($CODEX_HOME 优先,否则 ~/.codex)。 */
36
+ export function locateHome(env = process.env, home = homedir()) {
37
+ return env.CODEX_HOME || path.join(home, '.codex')
38
+ }
39
+
40
+ /**
41
+ * 只读白名单:允许读取的绝对路径根列表。凭据/内部状态永不在内。
42
+ * @param home - Codex 数据根。
43
+ * @returns 绝对路径根数组(sessions/skills/hooks/memories 目录 + 三个文件)。
44
+ */
45
+ export function whitelist(home) {
46
+ const h = path.resolve(String(home ?? ''))
47
+ return [
48
+ path.join(h, 'sessions'),
49
+ path.join(h, 'skills'),
50
+ path.join(h, 'hooks'),
51
+ path.join(h, 'memories'),
52
+ path.join(h, 'AGENTS.md'),
53
+ path.join(h, 'CODEX.md'),
54
+ path.join(h, 'config.toml'),
55
+ ]
56
+ }
57
+
58
+ /** ISO 时间戳 → 毫秒(parseTime 风格 Date.parse;非法返回 null)。 */
59
+ function toMs(value) {
60
+ if (typeof value === 'string') {
61
+ const n = Date.parse(value)
62
+ if (Number.isFinite(n)) return n
63
+ } else if (typeof value === 'number' && Number.isFinite(value)) {
64
+ return value
65
+ }
66
+ return null
67
+ }
68
+
69
+ /** 首个非空人类文本:过滤 `<...>` 开头的 harness 注入块。 */
70
+ function firstHumanText(payload) {
71
+ const content = payload && payload.content
72
+ if (!Array.isArray(content)) return null
73
+ for (const block of content) {
74
+ if (block && block.type === 'input_text' && typeof block.text === 'string') {
75
+ const text = block.text.trim()
76
+ if (text && !text.startsWith('<')) return text
77
+ }
78
+ }
79
+ return null
80
+ }
81
+
82
+ function isNotFound(err) {
83
+ return !!(err && err.code === 'ENOENT')
84
+ }
85
+
86
+ function throwIfAborted(signal) {
87
+ if (signal && signal.aborted) {
88
+ const err = new Error('扫描已中止')
89
+ err.name = 'AbortError'
90
+ throw err
91
+ }
92
+ }
93
+
94
+ function isAbort(err, signal) {
95
+ return !!(err && err.name === 'AbortError') || !!(signal && signal.aborted)
96
+ }
97
+
98
+ /** 相对路径 → 正斜杠 id(跨平台稳定)。 */
99
+ function toPosix(rel) {
100
+ return String(rel).split(path.sep).join('/')
101
+ }
102
+
103
+ /** 路径存在性检查(不抛)。 */
104
+ async function exists(p) {
105
+ try {
106
+ await access(p)
107
+ return true
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 读取单个白名单文件:越界/读取失败记入 errors;缺文件返回 null(不报错)。
115
+ * @returns `{ file, content }`;缺文件/失败返回 null。
116
+ */
117
+ async function readOptional(detection, roots, file, scope, signal) {
118
+ throwIfAborted(signal)
119
+ let abs
120
+ try {
121
+ abs = assertAllowedRead(roots, file)
122
+ } catch (err) {
123
+ recordError(detection, scope, err)
124
+ return null
125
+ }
126
+ try {
127
+ const content = await readFile(abs, 'utf8')
128
+ return { file: abs, content }
129
+ } catch (err) {
130
+ if (isAbort(err, signal)) throw err
131
+ if (isNotFound(err)) return null
132
+ recordError(detection, scope, err)
133
+ return null
134
+ }
135
+ }
136
+
137
+ /** 流式逐行扫单个 rollout JSONL,统计会话条目(不整文件进内存)。 */
138
+ async function scanSession(file, roots, signal) {
139
+ const abs = assertAllowedRead(roots, file)
140
+ let id = null
141
+ let cwd = null
142
+ let createdAt = null
143
+ let lastActivity = null
144
+ let title = null
145
+ let turns = 0
146
+ let messages = 0
147
+ let toolCalls = 0
148
+ let malformed = 0
149
+
150
+ const rl = createInterface({ input: createReadStream(abs), crlfDelay: Infinity })
151
+ for await (const line of rl) {
152
+ throwIfAborted(signal)
153
+ const text = line.trim()
154
+ if (!text) continue
155
+ let rec
156
+ try {
157
+ rec = JSON.parse(text)
158
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
159
+ throw new SyntaxError('record is not a JSON object')
160
+ }
161
+ } catch {
162
+ malformed++
163
+ continue
164
+ }
165
+
166
+ const ts = toMs(rec.timestamp)
167
+ if (ts != null) lastActivity = lastActivity == null ? ts : Math.max(lastActivity, ts)
168
+
169
+ const env = rec.type
170
+ const payload = rec.payload
171
+ if (env === 'session_meta' && payload) {
172
+ if (id == null && typeof payload.id === 'string') id = payload.id
173
+ if (cwd == null && typeof payload.cwd === 'string') cwd = payload.cwd
174
+ if (createdAt == null) {
175
+ const created = toMs(payload.timestamp)
176
+ if (created != null) createdAt = created
177
+ }
178
+ continue
179
+ }
180
+ if (env === 'turn_context' && payload) {
181
+ turns++
182
+ continue
183
+ }
184
+ if (env !== 'response_item' || !payload) continue
185
+
186
+ if (payload.type === 'message') {
187
+ if (payload.role === 'user' || payload.role === 'assistant') messages++
188
+ if (payload.role === 'user' && title == null) {
189
+ const human = firstHumanText(payload)
190
+ if (human) title = truncateText(human, 120)
191
+ }
192
+ } else if (payload.type === 'function_call' || payload.type === 'custom_tool_call') {
193
+ toolCalls++
194
+ }
195
+ }
196
+
197
+ return {
198
+ id: id || path.basename(abs),
199
+ file: abs,
200
+ turns,
201
+ messages,
202
+ toolCalls,
203
+ malformed,
204
+ format: 'rollout-jsonl',
205
+ ...(title ? { title } : {}),
206
+ ...(cwd ? { cwd } : {}),
207
+ ...(createdAt != null ? { createdAt } : {}),
208
+ ...(lastActivity != null ? { lastActivity } : {}),
209
+ }
210
+ }
211
+
212
+ async function scanSessions(detection, roots, home, signal) {
213
+ const dir = path.join(home, 'sessions')
214
+ throwIfAborted(signal)
215
+ let entries
216
+ try {
217
+ entries = await readdir(assertAllowedRead(roots, dir), { recursive: true })
218
+ } catch (err) {
219
+ if (isAbort(err, signal)) throw err
220
+ if (isNotFound(err)) return
221
+ recordError(detection, 'sessions', err)
222
+ return
223
+ }
224
+ for (const rel of entries) {
225
+ throwIfAborted(signal)
226
+ if (!ROLLOUT_FILE_RE.test(path.basename(rel))) continue
227
+ const file = path.join(dir, rel)
228
+ try {
229
+ detection.sessions.push(await scanSession(file, roots, signal))
230
+ } catch (err) {
231
+ if (isAbort(err, signal)) throw err
232
+ recordError(detection, 'session:' + file, err)
233
+ }
234
+ }
235
+ }
236
+
237
+ async function scanSkills(detection, roots, home, signal) {
238
+ const dir = path.join(home, 'skills')
239
+ throwIfAborted(signal)
240
+ let entries
241
+ try {
242
+ entries = await readdir(assertAllowedRead(roots, dir), { withFileTypes: true })
243
+ } catch (err) {
244
+ if (isAbort(err, signal)) throw err
245
+ if (isNotFound(err)) return
246
+ recordError(detection, 'skills', err)
247
+ return
248
+ }
249
+ for (const entry of entries) {
250
+ throwIfAborted(signal)
251
+ if (skipSkillEntry(entry.name)) continue
252
+ if (!entry.isDirectory()) continue
253
+ const skillFile = path.join(dir, entry.name, 'SKILL.md')
254
+ const read = await readOptional(detection, roots, skillFile, 'skill:' + skillFile, signal)
255
+ if (!read) continue
256
+ const classified = classifySkill(read.content)
257
+ detection.skills.push({
258
+ id: toPosix(path.relative(home, read.file)),
259
+ dir: path.dirname(read.file),
260
+ file: read.file,
261
+ name: classified.name || entry.name,
262
+ description: classified.description,
263
+ compatible: classified.compatible,
264
+ digest: digestText(read.content),
265
+ })
266
+ }
267
+ }
268
+
269
+ async function scanMemories(detection, roots, home, signal) {
270
+ const dir = path.join(home, 'memories')
271
+ throwIfAborted(signal)
272
+ let entries
273
+ try {
274
+ entries = await readdir(assertAllowedRead(roots, dir), { withFileTypes: true })
275
+ } catch (err) {
276
+ if (isAbort(err, signal)) throw err
277
+ if (isNotFound(err)) return
278
+ recordError(detection, 'memories', err)
279
+ return
280
+ }
281
+ for (const entry of entries) {
282
+ throwIfAborted(signal)
283
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
284
+ const file = path.join(dir, entry.name)
285
+ const read = await readOptional(detection, roots, file, 'memory:' + file, signal)
286
+ if (!read) continue
287
+ detection.memories.push({
288
+ id: toPosix(path.relative(home, read.file)),
289
+ file: read.file,
290
+ kind: 'codex-memory',
291
+ bytes: Buffer.byteLength(read.content, 'utf8'),
292
+ digest: digestText(read.content),
293
+ })
294
+ }
295
+ }
296
+
297
+ async function scanInstructions(detection, roots, home, signal) {
298
+ const targets = [
299
+ ['AGENTS.md', 'agents-md'],
300
+ ['CODEX.md', 'codex-md'],
301
+ ]
302
+ for (const [name, kind] of targets) {
303
+ throwIfAborted(signal)
304
+ const file = path.join(home, name)
305
+ const read = await readOptional(detection, roots, file, name, signal)
306
+ if (!read) continue
307
+ detection.instructions.push({
308
+ id: toPosix(path.relative(home, read.file)),
309
+ file: read.file,
310
+ kind,
311
+ bytes: Buffer.byteLength(read.content, 'utf8'),
312
+ digest: digestText(read.content),
313
+ })
314
+ }
315
+ }
316
+
317
+ /** 去掉 TOML 行内注释(尊重双/单引号字符串)。 */
318
+ function stripTomlComment(line) {
319
+ let quote = null
320
+ for (let i = 0; i < line.length; i++) {
321
+ const ch = line[i]
322
+ if (quote) {
323
+ if (ch === quote && line[i - 1] !== '\\') quote = null
324
+ } else if (ch === '"' || ch === "'") {
325
+ quote = ch
326
+ } else if (ch === '#') {
327
+ return line.slice(0, i)
328
+ }
329
+ }
330
+ return line
331
+ }
332
+
333
+ /** 去掉 TOML 字符串值的成对引号。 */
334
+ function unquoteToml(value) {
335
+ const quoted = value.match(/^"([\s\S]*)"$|^'([\s\S]*)'$/)
336
+ if (!quoted) return value
337
+ return quoted[1] ?? quoted[2]
338
+ }
339
+
340
+ /** 最小 TOML 解析:提取 `[commands]` 段内的 `name = value` 条目。 */
341
+ function parseTomlCommands(toml) {
342
+ const commands = []
343
+ const lines = String(toml ?? '').split(/\r?\n/)
344
+ let inCommands = false
345
+ for (const raw of lines) {
346
+ const line = stripTomlComment(raw).trim()
347
+ if (!line) continue
348
+ const section = line.match(/^\[([^\]]+)\]$/)
349
+ if (section) {
350
+ inCommands = section[1].trim() === 'commands'
351
+ continue
352
+ }
353
+ if (!inCommands) continue
354
+ const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/)
355
+ if (!kv) continue
356
+ commands.push({ name: kv[1], value: unquoteToml(kv[2].trim()) })
357
+ }
358
+ return commands
359
+ }
360
+
361
+ async function scanConfigCommands(detection, roots, home, signal) {
362
+ const file = path.join(home, 'config.toml')
363
+ const read = await readOptional(detection, roots, file, 'config.toml', signal)
364
+ if (!read) return
365
+ let commands
366
+ try {
367
+ commands = parseTomlCommands(read.content)
368
+ } catch (err) {
369
+ recordError(detection, 'config.toml', err)
370
+ return
371
+ }
372
+ for (const cmd of commands) {
373
+ detection.hooks.push({
374
+ id: `config.toml:${cmd.name}`,
375
+ file: read.file,
376
+ kind: 'codex-hook',
377
+ matcher: cmd.name,
378
+ bytes: Buffer.byteLength(cmd.value, 'utf8'),
379
+ })
380
+ }
381
+ }
382
+
383
+ async function scanCommandsAndHooks(detection, roots, home, signal) {
384
+ const hooksDir = path.join(home, 'hooks')
385
+ throwIfAborted(signal)
386
+ let entries
387
+ try {
388
+ entries = await readdir(assertAllowedRead(roots, hooksDir), { withFileTypes: true })
389
+ } catch (err) {
390
+ if (isAbort(err, signal)) throw err
391
+ if (isNotFound(err)) entries = []
392
+ else {
393
+ recordError(detection, 'hooks', err)
394
+ entries = []
395
+ }
396
+ }
397
+
398
+ for (const entry of entries) {
399
+ throwIfAborted(signal)
400
+ if (!entry.isDirectory()) continue
401
+ const dir = path.join(hooksDir, entry.name)
402
+
403
+ const command = await readOptional(detection, roots, path.join(dir, 'command.md'), 'command:' + entry.name, signal)
404
+ if (command) {
405
+ const classified = classifyCommand(command.content, entry.name)
406
+ detection.commands.push({
407
+ id: toPosix(path.relative(home, command.file)),
408
+ file: command.file,
409
+ name: classified.name,
410
+ promptOnly: classified.promptOnly,
411
+ bytes: Buffer.byteLength(command.content, 'utf8'),
412
+ digest: digestText(command.content),
413
+ })
414
+ }
415
+
416
+ const prompt = await readOptional(detection, roots, path.join(dir, 'prompt.md'), 'prompt:' + entry.name, signal)
417
+ if (prompt) {
418
+ detection.hooks.push({
419
+ id: toPosix(path.relative(home, prompt.file)),
420
+ file: prompt.file,
421
+ kind: 'codex-hook',
422
+ bytes: Buffer.byteLength(prompt.content, 'utf8'),
423
+ })
424
+ }
425
+ }
426
+
427
+ await scanConfigCommands(detection, roots, home, signal)
428
+ }
429
+
430
+ /**
431
+ * 扫描 Codex 数据根为统一 Detection。
432
+ * @param home - 数据根目录。
433
+ * @param opts - `{ signal }`。
434
+ * @returns `{ source, home, homeExists, scannedAt, sessions, skills, memories,
435
+ * instructions, commands, hooks, errors }`。
436
+ */
437
+ export async function detect(home, { signal } = {}) {
438
+ const resolved = path.resolve(String(home ?? ''))
439
+ const detection = emptyDetection(source, resolved)
440
+ const roots = whitelist(resolved)
441
+ throwIfAborted(signal)
442
+ detection.homeExists = await exists(resolved)
443
+ if (!detection.homeExists) return detection
444
+
445
+ await scanSessions(detection, roots, resolved, signal)
446
+ await scanSkills(detection, roots, resolved, signal)
447
+ await scanMemories(detection, roots, resolved, signal)
448
+ await scanInstructions(detection, roots, resolved, signal)
449
+ await scanCommandsAndHooks(detection, roots, resolved, signal)
450
+ return detection
451
+ }
@@ -0,0 +1,145 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/sources/contract.mjs — 四源迁移共享契约(零 DSH 依赖)。
3
+ //
4
+ // 每个源在 lib/sources/<source>/ 下提供一个解析器(parser.mjs)与一个映射器
5
+ // (mapper.mjs),两者都只依赖本契约与零依赖工具模块(convert.mjs 会话合成、
6
+ // skill-migrate.mjs 技能兼容判定、commands-migrate.mjs 命令分类)。解析器负责
7
+ // 数据根定位 + 只读白名单内的结构化扫描;映射器把清单变成迁移计划/不支持清单,
8
+ // 纯函数、可独立单测。
9
+ //
10
+ // 解析器导出约定:
11
+ // export const source = 'codex'
12
+ // export function locateHome(env, home) // 数据根定位($CODEX_HOME / ~/.codex …)
13
+ // export function whitelist(home) // 允许读取的绝对路径根列表(只读白名单)
14
+ // export async function detect(home, opts) // 结构化清单,形状见下
15
+ //
16
+ // 映射器导出约定:
17
+ // export function mapSource(source, detection, opts) → { plans, unsupported }
18
+
19
+ import { createHash } from 'node:crypto'
20
+ import path from 'node:path'
21
+
22
+ /** 支持的四个源标识(stable)。 */
23
+ export const SOURCES = ['claude', 'codex', 'opencode', 'hermes']
24
+
25
+ /** 会话类计划与文件类计划共用的幂等键前缀。 */
26
+ export function planKey(source, kind, id) {
27
+ return `${source}:${kind}:${String(id ?? '')}`
28
+ }
29
+
30
+ /**
31
+ * 平台感知的路径包含判定(Windows 大小写不敏感)。
32
+ * @param root - 白名单根(绝对路径)。
33
+ * @param absPath - 待判定绝对路径。
34
+ * @returns boolean。
35
+ */
36
+ export function isInsideRoot(root, absPath) {
37
+ const rel = path.relative(root, absPath)
38
+ if (rel === '' || rel === '.') return true
39
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
40
+ return true
41
+ }
42
+
43
+ /**
44
+ * 只读白名单守卫:路径必须落在某个允许根内,否则大声失败(S1:绝不越界读隐私文件)。
45
+ * 每个源的 whitelist(home) 决定可读面;auth.json / state db / log / .env 等
46
+ * 凭据与内部状态永不出现在白名单里。
47
+ * @param roots - 允许根列表。
48
+ * @param absPath - 待读取的绝对路径。
49
+ * @returns 归一化后的绝对路径。
50
+ */
51
+ export function assertAllowedRead(roots, absPath) {
52
+ const target = path.resolve(String(absPath ?? ''))
53
+ const ok = (roots ?? []).some((root) => isInsideRoot(root, target))
54
+ if (!ok) {
55
+ throw new Error(`读取越界(白名单外):${target}`)
56
+ }
57
+ return target
58
+ }
59
+
60
+ /** 越界/失败文案的统一提取。 */
61
+ export function errorText(err) {
62
+ if (err && typeof err.message === 'string') return err.message
63
+ return String(err)
64
+ }
65
+
66
+ /** 内容摘要(幂等比对)。 */
67
+ export function digestText(text) {
68
+ return createHash('sha256').update(String(text ?? '')).digest('hex')
69
+ }
70
+
71
+ /** 标题截断(合成 fixture/映射器共用)。 */
72
+ export function truncateText(text, max = 120) {
73
+ const s = String(text ?? '').trim().replace(/\s+/g, ' ')
74
+ return s.length <= max ? s : s.slice(0, max - 3).trimEnd() + '...'
75
+ }
76
+
77
+ /** 解析器统一返回的计数/错误骨架。 */
78
+ export function emptyDetection(source, home) {
79
+ return {
80
+ source,
81
+ home,
82
+ homeExists: false,
83
+ scannedAt: new Date().toISOString(),
84
+ sessions: [],
85
+ skills: [],
86
+ memories: [],
87
+ instructions: [],
88
+ commands: [],
89
+ hooks: [],
90
+ errors: [],
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 合并多源检测为一份索引(move_detect 全量输出)。
96
+ * @param detections - 各源 detect() 结果。
97
+ * @returns `{ sources: Detection[], stats: { sessions, skills, memories, instructions, commands, hooks } }`。
98
+ */
99
+ export function mergeDetections(detections) {
100
+ const stats = { sessions: 0, skills: 0, memories: 0, instructions: 0, commands: 0, hooks: 0 }
101
+ for (const d of detections ?? []) {
102
+ stats.sessions += d.sessions?.length ?? 0
103
+ stats.skills += d.skills?.length ?? 0
104
+ stats.memories += d.memories?.length ?? 0
105
+ stats.instructions += d.instructions?.length ?? 0
106
+ stats.commands += d.commands?.length ?? 0
107
+ stats.hooks += d.hooks?.length ?? 0
108
+ }
109
+ return { sources: detections ?? [], stats }
110
+ }
111
+
112
+ /**
113
+ * 迁移计划形状(mapper 输出 / wizard 输入):
114
+ * {
115
+ * key, // 幂等键 = planKey(source, kind, id)
116
+ * from, // 'claude'|'codex'|'opencode'|'hermes'(源标识)
117
+ * kind, // 'session'|'skill'|'memory'|'instruction'|'command'|'hook'
118
+ * action, // 'import-session'|'copy'|'convert-copy'|'append-section'|'register-command'|'unsupported'
119
+ * source: { file?, dir?, name?, title?, ... }, // 源定位对象(与 from 区分,避免同名键覆盖)
120
+ * target: { path?, sessionId?, commandName? },
121
+ * digest?, // 文件类计划的内容摘要(幂等)
122
+ * content?, // 生成内容(append-section / convert-copy)
123
+ * companionDirs?, // 技能伴随目录(复制整个技能目录时)
124
+ * provider?, model?, title?, // session 类
125
+ * reason?, // unsupported 原因(含建议)
126
+ * }
127
+ * @param source - 源标识。
128
+ * @param kind - 计划种类。
129
+ * @param id - 条目 id。
130
+ * @param extra - 其余字段(source 对象/action/target 等)。
131
+ * @returns 计划对象。
132
+ */
133
+ export function makePlan(source, kind, id, extra = {}) {
134
+ return { key: planKey(source, kind, id), from: source, kind, ...extra }
135
+ }
136
+
137
+ /** 解析器统一的扫描错误记录。 */
138
+ export function recordError(detection, scope, err) {
139
+ detection.errors.push({ scope, error: errorText(err) })
140
+ }
141
+
142
+ /** 源标识是否为受支持四源之一。 */
143
+ export function isSourceName(value) {
144
+ return SOURCES.includes(value)
145
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/sources/hermes/mapper.mjs — Hermes 源映射器(四合一迁移向导,纯函数)。
3
+ //
4
+ // 清单 → 迁移计划:
5
+ // - skills → copy(兼容:name+description 齐备,内容直拷,只改落点目录名)或
6
+ // convert-copy(不兼容:合成 frontmatter 后写入 SKILL.md)。
7
+ // - memories(MEMORY.md / USER.md,§ 分隔条目)→ append-section(DSH 全局
8
+ // AGENTS.md 管理段,每文件一个独立段,内容原样迁移)。
9
+
10
+ import { readFileSync } from 'node:fs'
11
+ import { assertAllowedRead, planKey } from '../contract.mjs'
12
+ import { defaultAgentsMdPath } from '../../agmd-section.mjs'
13
+ import { skillTargetPath, kebabName } from '../../skill-migrate.mjs'
14
+ import { whitelist } from './parser.mjs'
15
+
16
+ /**
17
+ * 映射 Hermes 检测清单为迁移计划。
18
+ * @param source - 'hermes'。
19
+ * @param detection - hermes/parser.mjs 的 detect() 输出。
20
+ * @param opts - `{ skillsDir, agentsMdPath }`;agentsMdPath 缺省 defaultAgentsMdPath()。
21
+ * @returns `{ plans, errors }`。
22
+ */
23
+ export function mapSource(source, detection, opts = {}) {
24
+ const plans = []
25
+ const errors = []
26
+ const agentsMdPath = opts.agentsMdPath ?? defaultAgentsMdPath()
27
+ const skillsDir = opts.skillsDir
28
+ const roots = whitelist(detection.home)
29
+
30
+ for (const skill of detection.skills ?? []) {
31
+ plans.push({
32
+ key: planKey(source, 'skill', skill.id),
33
+ from: source,
34
+ kind: 'skill',
35
+ action: skill.compatible ? 'copy' : 'convert-copy',
36
+ source: { file: skill.file, dir: skill.dir, name: skill.name },
37
+ target: { path: skillTargetPath(skillsDir, kebabName(skill.name)) },
38
+ digest: skill.digest,
39
+ })
40
+ }
41
+
42
+ for (const memory of detection.memories ?? []) {
43
+ try {
44
+ const content = readFileSync(assertAllowedRead(roots, memory.file), 'utf8')
45
+ plans.push({
46
+ key: planKey(source, 'memory', memory.id),
47
+ from: source,
48
+ kind: 'memory',
49
+ action: 'append-section',
50
+ source: { file: memory.file, kind: memory.kind },
51
+ target: { path: agentsMdPath },
52
+ content,
53
+ digest: memory.digest,
54
+ })
55
+ } catch (err) {
56
+ errors.push(`memory:${memory.file}: ${String((err && err.message) || err)}`)
57
+ }
58
+ }
59
+
60
+ return { plans, errors }
61
+ }