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,266 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/sources/opencode/parser.mjs — OpenCode 源解析器(四合一迁移向导)。
3
+ //
4
+ // 数据布局(XDG,2026 实测):
5
+ // - 会话:`<dataHome>/opencode.db`(SQLite:session/message/part 表,node:sqlite
6
+ // 只读打开;message.data / part.data 是 JSON 文本列)——旧版为
7
+ // `storage/session/global/<ses>.json` + `storage/message/<ses>/msg_*.json` +
8
+ // `storage/part/<msg>/prt_*.json`,两条路径都支持(DB 优先)。
9
+ // - 配置:`<configHome>/agent/*.md`(子代理定义 → 转换为技能)、
10
+ // `<configHome>/command/*.md`(命令)、`<configHome>/AGENTS.md`(全局指令)。
11
+ // 只读白名单:opencode.db + storage 目录 + agent/command/AGENTS.md;
12
+ // auth.json / log/ / snapshot/ / bin/ / .env 永不读取。
13
+
14
+ import path from 'node:path'
15
+ import { homedir } from 'node:os'
16
+ import { existsSync } from 'node:fs'
17
+ import { readdir, readFile, stat } from 'node:fs/promises'
18
+ import { DatabaseSync } from 'node:sqlite'
19
+ import { assertAllowedRead, digestText, emptyDetection, errorText, recordError, truncateText } from '../contract.mjs'
20
+ import { classifySkill } from '../../skill-migrate.mjs'
21
+ import { classifyCommand } from '../../commands-migrate.mjs'
22
+
23
+ export const source = 'opencode'
24
+
25
+ /** 数据库扫描的会话数上限(按最近更新排序;防超大户 OOM)。 */
26
+ export const SESSION_SCAN_CAP = 500
27
+
28
+ /** 数据根定位:OPENCODE_DATA_HOME > XDG_DATA_HOME/opencode > 平台默认。 */
29
+ export function locateHome(env = process.env, home = homedir()) {
30
+ if (env.OPENCODE_DATA_HOME) return path.resolve(env.OPENCODE_DATA_HOME)
31
+ if (env.XDG_DATA_HOME) return path.join(env.XDG_DATA_HOME, 'opencode')
32
+ if (process.platform === 'win32') {
33
+ return path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'opencode')
34
+ }
35
+ return path.join(home, '.local', 'share', 'opencode')
36
+ }
37
+
38
+ /** 配置根定位:OPENCODE_CONFIG_HOME > XDG_CONFIG_HOME/opencode > ~/.config/opencode。 */
39
+ export function locateConfigHome(env = process.env, home = homedir()) {
40
+ if (env.OPENCODE_CONFIG_HOME) return path.resolve(env.OPENCODE_CONFIG_HOME)
41
+ if (env.XDG_CONFIG_HOME) return path.join(env.XDG_CONFIG_HOME, 'opencode')
42
+ return path.join(home, '.config', 'opencode')
43
+ }
44
+
45
+ /** 只读白名单:数据库 + 旧版 storage + 配置三件套。 */
46
+ export function whitelist(home, configHome = locateConfigHome()) {
47
+ return [
48
+ path.join(home, 'opencode.db'),
49
+ path.join(home, 'storage'),
50
+ path.join(configHome, 'agent'),
51
+ path.join(configHome, 'command'),
52
+ path.join(configHome, 'AGENTS.md'),
53
+ ]
54
+ }
55
+
56
+ /**
57
+ * 打开 opencode.db(只读)。表缺失/打不开时抛错(调用方记 errors)。
58
+ * @param dbPath - 数据库绝对路径。
59
+ * @returns DatabaseSync 实例。
60
+ */
61
+ export function openDb(dbPath) {
62
+ return new DatabaseSync(dbPath, { readOnly: true })
63
+ }
64
+
65
+ /** 从数据库读取全部会话的元数据(标题/目录/时间 + 轻量统计)。 */
66
+ function scanDb(detection, dbPath, cap = SESSION_SCAN_CAP) {
67
+ let db
68
+ try {
69
+ db = openDb(dbPath)
70
+ } catch (err) {
71
+ recordError(detection, 'opencode.db', '打开失败:' + errorText(err))
72
+ return
73
+ }
74
+ try {
75
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name)
76
+ const need = ['session', 'message', 'part']
77
+ if (!need.every((t) => tables.includes(t))) {
78
+ recordError(detection, 'opencode.db', `缺少表:${need.filter((t) => !tables.includes(t)).join(', ')}(不认识的 OpenCode 版本)`)
79
+ return
80
+ }
81
+ const sessions = db.prepare(
82
+ 'SELECT id, title, directory, time_created, time_updated FROM session WHERE time_archived IS NULL ORDER BY time_updated DESC LIMIT ?',
83
+ ).all(cap)
84
+ const msgStmt = db.prepare("SELECT data FROM message WHERE session_id = ?")
85
+ const partStmt = db.prepare(
86
+ "SELECT COUNT(*) AS n FROM part WHERE session_id = ? AND json_extract(data, '$.type') = 'tool'",
87
+ )
88
+ for (const s of sessions) {
89
+ let turns = 0
90
+ let messages = 0
91
+ for (const row of msgStmt.all(s.id)) {
92
+ messages++
93
+ try {
94
+ const data = JSON.parse(row.data)
95
+ if (data?.role === 'user') turns++
96
+ } catch {
97
+ // 畸形 message.data:宽容跳过(转换期再报行级错误)。
98
+ }
99
+ }
100
+ const toolCalls = partStmt.get(s.id)?.n ?? 0
101
+ detection.sessions.push({
102
+ id: s.id,
103
+ file: dbPath,
104
+ sessionId: s.id,
105
+ storage: 'opencode-db',
106
+ ...(s.title ? { title: truncateText(s.title, 120) } : {}),
107
+ ...(s.directory ? { cwd: s.directory } : {}),
108
+ createdAt: typeof s.time_created === 'number' ? s.time_created : undefined,
109
+ lastActivity: typeof s.time_updated === 'number' ? s.time_updated : undefined,
110
+ turns,
111
+ messages,
112
+ toolCalls,
113
+ format: 'opencode-db',
114
+ })
115
+ }
116
+ } catch (err) {
117
+ recordError(detection, 'opencode.db', errorText(err))
118
+ } finally {
119
+ try { db.close() } catch { /* 关闭失败无碍 */ }
120
+ }
121
+ }
122
+
123
+ /** 旧版 JSON 布局扫描:storage/session/global/*.json + storage/message/<ses>/msg_*.json。 */
124
+ async function scanLegacy(detection, storageDir, cap = SESSION_SCAN_CAP) {
125
+ const sessionDir = path.join(storageDir, 'session', 'global')
126
+ let names
127
+ try {
128
+ names = (await readdir(sessionDir)).filter((n) => n.endsWith('.json'))
129
+ } catch {
130
+ return // 无旧版布局。
131
+ }
132
+ const sessions = []
133
+ for (const name of names) {
134
+ try {
135
+ const raw = await readFile(assertAllowedRead(whitelist(detection.home), path.join(sessionDir, name)), 'utf8')
136
+ const data = JSON.parse(raw)
137
+ sessions.push({ name, data })
138
+ } catch (err) {
139
+ recordError(detection, 'legacy-session:' + name, errorText(err))
140
+ }
141
+ }
142
+ sessions.sort((a, b) => ((b.data?.time?.updated ?? 0) - (a.data?.time?.updated ?? 0)))
143
+ for (const { name, data } of sessions.slice(0, cap)) {
144
+ const id = data?.id ?? name.replace(/\.json$/, '')
145
+ const msgDir = path.join(storageDir, 'message', id)
146
+ let messages = 0
147
+ try {
148
+ const entries = await readdir(assertAllowedRead(whitelist(detection.home), msgDir))
149
+ messages = entries.filter((n) => n.startsWith('msg_') && n.endsWith('.json')).length
150
+ } catch {
151
+ // 消息目录缺失:按 0 处理。
152
+ }
153
+ detection.sessions.push({
154
+ id,
155
+ file: path.join(sessionDir, name),
156
+ sessionId: id,
157
+ storage: 'opencode-legacy',
158
+ ...(data?.title ? { title: truncateText(data.title, 120) } : {}),
159
+ ...(data?.directory ? { cwd: data.directory } : {}),
160
+ createdAt: data?.time?.created,
161
+ lastActivity: data?.time?.updated,
162
+ turns: 0,
163
+ messages,
164
+ toolCalls: 0,
165
+ format: 'opencode-legacy',
166
+ })
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 扫描 OpenCode:数据库优先,旧版 storage 兜底;agents/commands/AGENTS.md 走配置根。
172
+ * @param home - 数据根目录。
173
+ * @param opts - `{ configHome, signal }`。
174
+ * @returns 统一 Detection。
175
+ */
176
+ export async function detect(home, { configHome = locateConfigHome(), signal } = {}) {
177
+ const detection = emptyDetection(source, home)
178
+ detection.configHome = configHome
179
+ detection.homeExists = existsSync(home) || existsSync(configHome)
180
+ if (!detection.homeExists) return detection
181
+
182
+ const roots = whitelist(home, configHome)
183
+ const dbPath = path.join(home, 'opencode.db')
184
+ if (existsSync(dbPath)) {
185
+ scanDb(detection, dbPath)
186
+ }
187
+ if (detection.sessions.length === 0) {
188
+ await scanLegacy(detection, path.join(home, 'storage'))
189
+ }
190
+
191
+ // agents:<configHome>/agent/*.md → 技能条目(一律转换为 SKILL.md)。
192
+ try {
193
+ signal?.throwIfAborted()
194
+ const agentDir = path.join(configHome, 'agent')
195
+ const entries = await readdir(assertAllowedRead(roots, agentDir), { withFileTypes: true })
196
+ for (const entry of entries) {
197
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
198
+ const file = path.join(agentDir, entry.name)
199
+ try {
200
+ const content = await readFile(assertAllowedRead(roots, file), 'utf8')
201
+ const { name, description } = classifySkill(content)
202
+ detection.skills.push({
203
+ id: 'agent:' + entry.name,
204
+ dir: agentDir,
205
+ file,
206
+ name: name || entry.name.replace(/\.md$/, ''),
207
+ description: description || '',
208
+ compatible: false, // agent 定义 → 统一转换(合成 name/description frontmatter)
209
+ digest: digestText(content),
210
+ })
211
+ } catch (err) {
212
+ recordError(detection, 'agent:' + file, err)
213
+ }
214
+ }
215
+ } catch {
216
+ // 无 agent 目录。
217
+ }
218
+
219
+ // commands:<configHome>/command/*.md → 命令条目(纯提示词可注册,含 shell 不支持)。
220
+ try {
221
+ signal?.throwIfAborted()
222
+ const commandDir = path.join(configHome, 'command')
223
+ const entries = await readdir(assertAllowedRead(roots, commandDir), { withFileTypes: true })
224
+ for (const entry of entries) {
225
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
226
+ const file = path.join(commandDir, entry.name)
227
+ try {
228
+ const content = await readFile(assertAllowedRead(roots, file), 'utf8')
229
+ const name = entry.name.replace(/\.md$/, '')
230
+ const classified = classifyCommand(content, name)
231
+ detection.commands.push({
232
+ id: name,
233
+ file,
234
+ name: classified.name,
235
+ promptOnly: classified.promptOnly,
236
+ bytes: Buffer.byteLength(content, 'utf8'),
237
+ digest: digestText(content),
238
+ })
239
+ } catch (err) {
240
+ recordError(detection, 'command:' + file, err)
241
+ }
242
+ }
243
+ } catch {
244
+ // 无 command 目录。
245
+ }
246
+
247
+ // instructions:全局 AGENTS.md。
248
+ const agentsMd = path.join(configHome, 'AGENTS.md')
249
+ try {
250
+ signal?.throwIfAborted()
251
+ const st = await stat(assertAllowedRead(roots, agentsMd))
252
+ if (st.isFile()) {
253
+ const content = await readFile(agentsMd, 'utf8')
254
+ detection.instructions.push({
255
+ id: agentsMd,
256
+ file: agentsMd,
257
+ kind: 'agents-md',
258
+ bytes: st.size,
259
+ digest: digestText(content),
260
+ })
261
+ }
262
+ } catch {
263
+ // 无全局 AGENTS.md。
264
+ }
265
+ return detection
266
+ }
package/lib/wizard.mjs ADDED
@@ -0,0 +1,329 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/wizard.mjs — 四合一迁移向导核心(纯编排,零 DSH 依赖)。
3
+ //
4
+ // 阶段:detect(四源扫描)→ plan(清单→迁移计划)→ preview(状态/diff/冲突)→
5
+ // execute(审批门 + 逐项落盘 + manifest 幂等记录)→ report(汇总 + 不支持清单)。
6
+ // 所有副作用经注入的 runtime 端口执行(index.mjs 接线到 DSH 服务),本模块
7
+ // 只做状态机与幂等判定,可完全用假 runtime 单测。
8
+ //
9
+ // 幂等:manifest(move.json)按 key 记录 { digest, target, action, sectionDigest?,
10
+ // targetDigest?, appliedAt };preview 据此判 new/unchanged/changed/conflict,
11
+ // execute 重跑只写变化项;force 对非冲突项重新应用。
12
+ // 冲突:目标已存在且内容与我们的记录不符(用户手工改过)→ diff 供选择,
13
+ // resolve = { key: 'skip'|'overwrite'|'rename'|'merge' }(默认 skip,绝不猜测)。
14
+
15
+ import { digestText } from './sources/contract.mjs'
16
+
17
+ /** 单计划执行结果的状态词表。 */
18
+ export const EXEC_STATUS = ['applied', 'skipped', 'conflict-skipped', 'unsupported', 'failed', 'not-approved']
19
+
20
+ /**
21
+ * 预览一份计划:返回 `{ plan, status, diff?, reason? }`。
22
+ * status ∈ new | unchanged | changed | conflict | unsupported。
23
+ * @param runtime - 运行时端口(见 index.mjs 接线)。
24
+ * @param plan - 迁移计划。
25
+ * @param manifest - 已加载清单。
26
+ * @param force - 是否强制(unchanged 也重应用)。
27
+ * @returns 预览条目。
28
+ */
29
+ export async function previewPlan(runtime, plan, manifest, force = false) {
30
+ const rec = manifest[plan.key]
31
+
32
+ if (plan.action === 'unsupported') {
33
+ return { plan, status: 'unsupported', reason: plan.reason }
34
+ }
35
+ if (plan.action === 'import-session') {
36
+ const state = await runtime.sessionStatus(plan.source)
37
+ if (state === 'updates') return { plan, status: 'changed', reason: '源会话有新增轮次(将增量续写)' }
38
+ if (state === 'imported') return force
39
+ ? { plan, status: 'changed', reason: 'force:另存完整副本' }
40
+ : { plan, status: 'unchanged', reason: '已导入' }
41
+ return { plan, status: 'new' }
42
+ }
43
+ if (plan.action === 'register-command') {
44
+ if (runtime.hasCommand && runtime.hasCommand(plan.target.commandName)) {
45
+ return force && (!rec || rec.digest !== plan.digest)
46
+ ? { plan, status: 'changed', reason: 'force:重新注册' }
47
+ : { plan, status: 'unchanged', reason: '命令已注册' }
48
+ }
49
+ return { plan, status: 'new' }
50
+ }
51
+ if (plan.action === 'append-section') {
52
+ const current = await runtime.readTarget(plan.target.path)
53
+ const { planSection } = await import('./agmd-section.mjs')
54
+ const step = planSection(current, plan.key, plan.content, plan.source.file)
55
+ if (step.status === 'unchanged') return { plan, status: 'unchanged' }
56
+ if (step.status === 'new') return { plan, status: 'new' }
57
+ // replace:区分「源更新」(我们的记录还在)与「用户改过」(冲突)。
58
+ const stored = rec?.sectionDigest
59
+ const newDigest = digestText(step.newContent)
60
+ if (stored && digestText(step.oldContent) === stored) {
61
+ return { plan, status: 'changed', reason: '源文件内容更新' }
62
+ }
63
+ if (digestText(step.oldContent) === newDigest) return { plan, status: 'unchanged' }
64
+ return {
65
+ plan,
66
+ status: 'conflict',
67
+ reason: '目标段已被手工修改(或来自旧版本),diff 供选择',
68
+ diff: step.diff,
69
+ oldContent: step.oldContent,
70
+ newContent: step.newContent,
71
+ }
72
+ }
73
+ // 技能类:copy / convert-copy
74
+ const targetText = await runtime.readTarget(plan.target.path)
75
+ const targetExists = targetText !== null
76
+ if (!targetExists) {
77
+ if (rec && rec.digest === plan.digest) {
78
+ return { plan, status: 'changed', reason: '目标缺失(可能被删除),重新应用' }
79
+ }
80
+ return { plan, status: 'new' }
81
+ }
82
+ if (!rec) {
83
+ return {
84
+ plan,
85
+ status: 'conflict',
86
+ reason: '目标已存在且无迁移记录,diff 供选择',
87
+ existing: targetText.length > 800 ? targetText.slice(0, 800) + '\n…(截断)' : targetText,
88
+ }
89
+ }
90
+ if (rec.digest === plan.digest && rec.targetDigest === digestText(targetText)) {
91
+ return force ? { plan, status: 'changed', reason: 'force:重新应用' } : { plan, status: 'unchanged' }
92
+ }
93
+ if (rec.targetDigest === digestText(targetText)) {
94
+ return { plan, status: 'changed', reason: '源技能内容更新' }
95
+ }
96
+ return {
97
+ plan,
98
+ status: 'conflict',
99
+ reason: '目标被手工修改(或来自旧版本),diff 供选择',
100
+ existing: targetText.length > 800 ? targetText.slice(0, 800) + '\n…(截断)' : targetText,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * 全量预览:每计划一条预览 + 冲突清单 + 可执行计数。
106
+ * @param runtime - 运行时端口。
107
+ * @param plans - 迁移计划数组。
108
+ * @param manifest - 已加载清单。
109
+ * @param force - 强制。
110
+ * @returns `{ previews, conflicts, counts: {new, unchanged, changed, conflict, unsupported} }`。
111
+ */
112
+ export async function runPreview(runtime, plans, manifest, force = false) {
113
+ const previews = []
114
+ const conflicts = []
115
+ const counts = { new: 0, unchanged: 0, changed: 0, conflict: 0, unsupported: 0 }
116
+ for (const plan of plans) {
117
+ const entry = await previewPlan(runtime, plan, manifest, force)
118
+ counts[entry.status] = (counts[entry.status] ?? 0) + 1
119
+ if (entry.status === 'conflict') conflicts.push(entry)
120
+ previews.push(entry)
121
+ }
122
+ return { previews, conflicts, counts }
123
+ }
124
+
125
+ /**
126
+ * 执行迁移(副作用全部经 runtime 端口):
127
+ * - 审批门:有实际写入动作且 requireApproval 时先调 runtime.approval,
128
+ * 非 allowed-once 一律零写入(fail-closed)。
129
+ * - 逐项执行前重算预览(TOCTOU 防护):冲突项按 resolve 处理,默认跳过。
130
+ * - 每项成功后经 runtime.record 写入 manifest 记录。
131
+ * @param runtime - 运行时端口。
132
+ * @param opts - `{ plans, manifest, resolve, force, selection, requireApproval, approval, signal }`。
133
+ * @returns `{ approved, results, applied, skipped, conflictSkipped, unsupported, failed }`。
134
+ */
135
+ export async function runExecute(runtime, opts = {}) {
136
+ const { resolve = {}, force = false, requireApproval = true, approval } = opts
137
+ const selection = new Set(opts.selection ?? [])
138
+ const manifest = opts.manifest
139
+ const results = []
140
+ const tally = { applied: 0, skipped: 0, conflictSkipped: 0, unsupported: 0, failed: 0, newSessions: [] }
141
+
142
+ // 审批门(fail-closed):只审批会真正产生写入的计划。
143
+ const actionable = opts.plans.filter((p) =>
144
+ p.action !== 'unsupported' && (selection.size === 0 || selection.has(p.key)))
145
+ const previews = new Map()
146
+ for (const plan of actionable) {
147
+ previews.set(plan.key, await previewPlan(runtime, plan, manifest, force))
148
+ }
149
+ const writes = [...previews.values()].filter((e) => e.status === 'new' || e.status === 'changed')
150
+ if (writes.length > 0 && requireApproval) {
151
+ const outcome = await approval({
152
+ ...(opts.approvalContext ?? {}),
153
+ reason: `四合一迁移将写入 ${writes.length} 项(会话 ${writes.filter((e) => e.plan.action === 'import-session').length} 个、技能 ${writes.filter((e) => e.plan.kind === 'skill').length} 个、AGENTS.md 段 ${writes.filter((e) => e.plan.action === 'append-section').length} 个、命令 ${writes.filter((e) => e.plan.action === 'register-command').length} 个)`,
154
+ })
155
+ if (outcome !== 'allowed-once') {
156
+ return {
157
+ approved: false,
158
+ outcome,
159
+ results: writes.map((e) => ({ key: e.plan.key, status: 'not-approved', detail: `审批未通过(${outcome})` })),
160
+ ...tally,
161
+ }
162
+ }
163
+ }
164
+
165
+ for (const plan of opts.plans) {
166
+ if (selection.size > 0 && !selection.has(plan.key)) {
167
+ results.push({ key: plan.key, status: 'skipped', detail: '未选中' })
168
+ tally.skipped++
169
+ continue
170
+ }
171
+ const entry = previews.get(plan.key) ?? await previewPlan(runtime, plan, manifest, force)
172
+ const exec = await executePlan(runtime, plan, entry, resolve[plan.key] ?? 'skip', force)
173
+ results.push(exec)
174
+ if (exec.status === 'applied') tally.applied++
175
+ else if (exec.status === 'skipped') tally.skipped++
176
+ else if (exec.status === 'conflict-skipped') tally.conflictSkipped++
177
+ else if (exec.status === 'unsupported') tally.unsupported++
178
+ else if (exec.status === 'failed') tally.failed++
179
+ if (exec.status === 'applied' && plan.action === 'import-session' && exec.detail?.sessionId) {
180
+ tally.newSessions.push({ key: plan.key, sessionId: exec.detail.sessionId, title: plan.title })
181
+ }
182
+ }
183
+ return { approved: true, outcome: 'allowed-once', results, ...tally }
184
+ }
185
+
186
+ /** 执行单个计划(含冲突解法与 manifest 记录)。 */
187
+ async function executePlan(runtime, plan, entry, resolution, force) {
188
+ const fail = (err) => ({ key: plan.key, status: 'failed', detail: String((err && err.message) || err) })
189
+
190
+ if (plan.action === 'unsupported') {
191
+ return { key: plan.key, status: 'unsupported', detail: plan.reason ?? '不支持' }
192
+ }
193
+ if (entry.status === 'unsupported') {
194
+ return { key: plan.key, status: 'unsupported', detail: plan.reason ?? entry.reason ?? '不支持' }
195
+ }
196
+ if (entry.status === 'unchanged' && !force) {
197
+ return { key: plan.key, status: 'skipped', detail: '已迁移(幂等跳过)' }
198
+ }
199
+ if (entry.status === 'conflict') {
200
+ // merge 只对 append-section 有意义;技能类 merge 按 skip 处理(不猜测)。
201
+ const unusable = plan.action !== 'append-section' && resolution === 'merge'
202
+ if (resolution === 'skip' || !resolution || unusable) {
203
+ return { key: plan.key, status: 'conflict-skipped', detail: '目标冲突且未选择解法(默认跳过)' }
204
+ }
205
+ }
206
+
207
+ try {
208
+ if (plan.action === 'import-session') {
209
+ const result = await runtime.importSession(plan, { force })
210
+ return { key: plan.key, status: 'applied', detail: result }
211
+ }
212
+ if (plan.action === 'register-command') {
213
+ const result = await runtime.registerCommand(plan.target.commandName, plan.content)
214
+ if (result.registered === false) {
215
+ return { key: plan.key, status: 'failed', detail: result.reason ?? '命令注册失败' }
216
+ }
217
+ // 记录含提示词:插件重启后 apply 时按 manifest 重建命令注册。
218
+ await runtime.record(plan.key, { digest: plan.digest, action: 'register-command', target: plan.target.commandName, prompt: plan.content })
219
+ return { key: plan.key, status: 'applied', detail: { commandName: plan.target.commandName } }
220
+ }
221
+ if (plan.action === 'append-section') {
222
+ const target = await runtime.readTarget(plan.target.path)
223
+ const { planSection, mergedSection } = await import('./agmd-section.mjs')
224
+ let step
225
+ if (resolution === 'merge') {
226
+ step = mergedSection(target, plan.key, plan.content, plan.source.file)
227
+ } else {
228
+ step = planSection(target, plan.key, plan.content, plan.source.file)
229
+ }
230
+ if (step.status === 'unchanged') {
231
+ return { key: plan.key, status: 'skipped', detail: '段内容未变(幂等跳过)' }
232
+ }
233
+ const { sectionInner } = await import('./agmd-section.mjs')
234
+ await runtime.writeTarget(plan.target.path, step.text)
235
+ await runtime.record(plan.key, {
236
+ digest: plan.digest,
237
+ action: 'append-section',
238
+ target: plan.target.path,
239
+ sectionDigest: digestText(sectionInner(step.status === 'new' ? plan.content : step.newContent, plan.source.file)),
240
+ })
241
+ return { key: plan.key, status: 'applied', detail: { target: plan.target.path, section: step.status } }
242
+ }
243
+ // 技能类:copy / convert-copy
244
+ let targetPath = plan.target.path
245
+ if (resolution === 'rename') {
246
+ targetPath = await runtime.renameTarget(plan.target.path)
247
+ }
248
+ const sourceText = await runtime.readSource(plan.source.file)
249
+ if (sourceText === null) throw new Error('源技能文件不可读:' + plan.source.file)
250
+ const { renderSkill } = await import('./skill-migrate.mjs')
251
+ const rendered = plan.action === 'convert-copy'
252
+ ? renderSkill(sourceText, plan.source.name).content
253
+ : sourceText
254
+ await runtime.writeTarget(targetPath, rendered)
255
+ await runtime.record(plan.key, {
256
+ digest: plan.digest,
257
+ targetDigest: digestText(rendered),
258
+ action: plan.action,
259
+ target: targetPath,
260
+ })
261
+ return { key: plan.key, status: 'applied', detail: { target: targetPath, converted: plan.action === 'convert-copy' } }
262
+ } catch (err) {
263
+ return fail(err)
264
+ }
265
+ }
266
+
267
+ /**
268
+ * 报告行渲染(模型可读摘要;报告文本以短 persona 语句开头,见 persona.mjs)。
269
+ * @param execResult - runExecute 输出。
270
+ * @param lang - 'en' | 'zh'。
271
+ * @returns 行数组。
272
+ */
273
+ export function reportLines(execResult, lang = 'zh') {
274
+ const zh = lang === 'zh'
275
+ const r = execResult
276
+ const lines = []
277
+ if (r.approved === false) {
278
+ lines.push(zh
279
+ ? `迁移未执行:审批未通过(${r.outcome ?? 'unavailable'})。零写入。`
280
+ : `Migration not executed: approval failed (${r.outcome ?? 'unavailable'}). Nothing was written.`)
281
+ return lines
282
+ }
283
+ lines.push(zh
284
+ ? `迁移完成:应用 ${r.applied} 项、幂等跳过 ${r.skipped} 项、冲突未处理 ${r.conflictSkipped} 项、不支持 ${r.unsupported} 项、失败 ${r.failed} 项。`
285
+ : `Migration done: ${r.applied} applied, ${r.skipped} skipped (idempotent), ${r.conflictSkipped} conflicts left, ${r.unsupported} unsupported, ${r.failed} failed.`)
286
+ for (const s of r.newSessions ?? []) {
287
+ lines.push(zh
288
+ ? `- 会话 ${s.sessionId}(${s.title ?? '无标题'})已导入,可继续对话。`
289
+ : `- Session ${s.sessionId} (${s.title ?? 'untitled'}) imported and resumable.`)
290
+ }
291
+ for (const res of r.results ?? []) {
292
+ if (res.status === 'failed') lines.push(zh ? `- 失败:${res.key}(${res.detail})` : `- Failed: ${res.key} (${res.detail})`)
293
+ if (res.status === 'unsupported') lines.push(zh ? `- 不支持:${res.key}(${res.detail})` : `- Unsupported: ${res.key} (${res.detail})`)
294
+ if (res.status === 'conflict-skipped') lines.push(zh ? `- 冲突未处理:${res.key}(${res.detail})` : `- Conflict left: ${res.key} (${res.detail})`)
295
+ }
296
+ return lines
297
+ }
298
+
299
+ /**
300
+ * 一步式向导(/move 命令与组合工具用):detect → plan → preview → execute → report。
301
+ * @param runtime - 运行时端口(含 detect/map 接口)。
302
+ * @param opts - `{ sources, force, resolve, selection, requireApproval, signal }`。
303
+ * @returns `{ detections, plans, preview, execution }`。
304
+ */
305
+ export async function runWizard(runtime, opts = {}) {
306
+ const sources = opts.sources ?? []
307
+ const detections = []
308
+ for (const source of sources) {
309
+ detections.push(await runtime.detect(source))
310
+ }
311
+ const plans = []
312
+ const mapErrors = []
313
+ for (const detection of detections) {
314
+ const mapped = await runtime.map(detection.source, detection)
315
+ plans.push(...(mapped.plans ?? []))
316
+ for (const err of mapped.errors ?? []) mapErrors.push(err)
317
+ }
318
+ const manifest = await runtime.loadManifest()
319
+ const preview = await runPreview(runtime, plans, manifest, opts.force)
320
+ const execution = await runExecute(runtime, {
321
+ plans, manifest, force: opts.force,
322
+ resolve: opts.resolve ?? {},
323
+ selection: opts.selection ?? [],
324
+ requireApproval: opts.requireApproval !== false,
325
+ approval: runtime.approval,
326
+ signal: opts.signal,
327
+ })
328
+ return { detections, plans, mapErrors, preview, execution }
329
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "dsh-claude-move",
3
+ "description": "Copy Claude Code transcripts, memories, skills and CLAUDE.md into DeepSeek Harness as resumable sessions 鈥?one workspace per project, copy-only, seamlessly resumable",
4
+ "version": "0.2.1",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./client": "./client/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "homepage": "https://github.com/PerryLink/dsh-claude-move#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/PerryLink/dsh-claude-move.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/PerryLink/dsh-claude-move/issues"
20
+ },
21
+ "files": [
22
+ "index.mjs",
23
+ "lib",
24
+ "client",
25
+ "assets",
26
+ "cordis.patch.yml",
27
+ "README.md",
28
+ "README.zh.md",
29
+ "README.es.md",
30
+ "README.pt.md",
31
+ "README.hi.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE",
34
+ "NOTICE",
35
+ "THIRD_PARTY_NOTICES.md"
36
+ ],
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "immediately": true
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.1",
48
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
49
+ "@deepseek-ai/schemastery": ">=3.0.0"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test \"test/*.test.mjs\""
53
+ },
54
+ "keywords": [
55
+ "deepseek-harness",
56
+ "dsh-plugin",
57
+ "claude-code",
58
+ "migration",
59
+ "session-import",
60
+ "resume"
61
+ ],
62
+ "engines": {
63
+ "node": "^22.19.0 || >=24.0.0"
64
+ },
65
+ "license": "Apache-2.0"
66
+ }