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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +201 -0
- package/NOTICE +23 -0
- package/README.es.md +291 -0
- package/README.hi.md +292 -0
- package/README.md +317 -0
- package/README.pt.md +291 -0
- package/README.zh.md +311 -0
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/assets/social-card.png +0 -0
- package/client/client.js +451 -0
- package/cordis.patch.yml +5 -0
- package/index.mjs +2891 -0
- package/lib/agmd-section.mjs +144 -0
- package/lib/commands-migrate.mjs +85 -0
- package/lib/context.mjs +156 -0
- package/lib/convert.mjs +725 -0
- package/lib/discovery.mjs +619 -0
- package/lib/frontmatter.mjs +58 -0
- package/lib/handoff.mjs +136 -0
- package/lib/imports-store.mjs +64 -0
- package/lib/manifest.mjs +73 -0
- package/lib/persona.mjs +37 -0
- package/lib/report.mjs +63 -0
- package/lib/settings.mjs +147 -0
- package/lib/skill-migrate.mjs +128 -0
- package/lib/skills-provider.mjs +219 -0
- package/lib/sources/claude/mapper.mjs +102 -0
- package/lib/sources/claude/parser.mjs +190 -0
- package/lib/sources/codex/mapper.mjs +120 -0
- package/lib/sources/codex/parser.mjs +451 -0
- package/lib/sources/contract.mjs +145 -0
- package/lib/sources/hermes/mapper.mjs +61 -0
- package/lib/sources/hermes/parser.mjs +152 -0
- package/lib/sources/opencode/convert.mjs +236 -0
- package/lib/sources/opencode/mapper.mjs +102 -0
- package/lib/sources/opencode/parser.mjs +266 -0
- package/lib/wizard.mjs +329 -0
- package/package.json +66 -0
|
@@ -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
|
+
}
|