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,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/skills-provider.mjs — Claude Code 技能 → DSH SkillProvider(F12,零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// DSH 技能名必须是 kebab-case,Claude 技能名不做此保证,这里做归一化
|
|
5
|
+
// (小写、非 [a-z0-9-] 折叠为 '-'、冲突加 -2/-3 后缀)。候选只含名称与描述,
|
|
6
|
+
// 完整正文在 get() 时按需读取(与 DSH 技能契约一致)。发现约定沿用
|
|
7
|
+
// YYTbit/dsh-plugin-claude-bridge(MIT,见 THIRD_PARTY_NOTICES.md)。
|
|
8
|
+
//
|
|
9
|
+
// 对齐当前 DSH 技能契约:list(options)/get(candidate, options) 接收
|
|
10
|
+
// `{ cwd, signal }`;options.cwd 命中项目时把该项目 `<cwd>/.claude/skills`
|
|
11
|
+
// 一并暴露(Claude Code 项目级技能);candidate 携带 `path` 与 `metadata`
|
|
12
|
+
// 字段;list 尊重 signal 中止。
|
|
13
|
+
|
|
14
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { parseFrontmatter } from './frontmatter.mjs'
|
|
17
|
+
|
|
18
|
+
/** Claude 技能提供者的固定 provider 名(技能目录里可辨识)。 */
|
|
19
|
+
export const CLAUDE_SKILLS_PROVIDER = 'claude-move'
|
|
20
|
+
|
|
21
|
+
/** 归一化后的技能名必须满足的 DSH kebab-case 模式。 */
|
|
22
|
+
export const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
23
|
+
|
|
24
|
+
/** 全局技能 rank(更低者赢得同名冲突:全局优先于项目级)。 */
|
|
25
|
+
export const GLOBAL_SKILL_RANK = 260
|
|
26
|
+
|
|
27
|
+
/** 项目级技能 rank(高于全局:同名时全局技能胜出)。 */
|
|
28
|
+
export const PROJECT_SKILL_RANK = 280
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 把任意技能名归一化为 kebab-case。
|
|
32
|
+
* @param raw - 原始名称。
|
|
33
|
+
* @param fallback - 归一化结果为空时使用。
|
|
34
|
+
* @returns kebab-case 名称。
|
|
35
|
+
*/
|
|
36
|
+
export function kebabName(raw, fallback = 'skill') {
|
|
37
|
+
const slug = String(raw ?? '')
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
40
|
+
.replace(/^-+|-+$/g, '')
|
|
41
|
+
.replace(/-{2,}/g, '-')
|
|
42
|
+
return slug.length > 0 ? slug : fallback
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 读取一个技能文件(SKILL.md 或扁平 .md)。DSH 技能系统要求每个技能必须有
|
|
47
|
+
* 非空 name 与 description(空 description 会直接抛错并使整个技能目录加载
|
|
48
|
+
* 失败,见 issue#1);缺失或为空时跳过该文件(与官方 dsh-skill-filesystem
|
|
49
|
+
* 的「warn + ignore」行为一致),绝不产出非法候选。
|
|
50
|
+
* @param file - 绝对路径。
|
|
51
|
+
* @returns `{ name, description, argumentHint?, level?, content, path, meta }` 或 null。
|
|
52
|
+
*/
|
|
53
|
+
async function readSkillFile(file) {
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(file, 'utf8')
|
|
56
|
+
const { meta, body } = parseFrontmatter(content)
|
|
57
|
+
if (body.trim().length === 0) return null
|
|
58
|
+
const name = (meta.name ?? path.basename(file).replace(/\.md$/, '')).trim()
|
|
59
|
+
const description = (meta.description ?? '').trim()
|
|
60
|
+
if (name.length === 0 || description.length === 0) return null
|
|
61
|
+
const level = meta.level ? Number.parseInt(meta.level, 10) : undefined
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
description,
|
|
65
|
+
...(meta['argument-hint'] ? { argumentHint: meta['argument-hint'] } : {}),
|
|
66
|
+
...(Number.isFinite(level) ? { level } : {}),
|
|
67
|
+
content: body,
|
|
68
|
+
path: file,
|
|
69
|
+
meta,
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 发现一个技能根目录下的全部技能文件:`<root>/<name>/SKILL.md` 与 `<root>/<name>.md`。
|
|
78
|
+
* 显式排除非技能文档:MEMORY.md 与 README.md(Claude skills 目录惯用的
|
|
79
|
+
* 索引/说明文件,无 name/description frontmatter,误注册会令 DSH 技能加载
|
|
80
|
+
* 整体失败,issue#1)。排除文件名匹配大小写不敏感。
|
|
81
|
+
* @param root - 技能根目录。
|
|
82
|
+
* @param signal - 可选 AbortSignal;中止时抛出 signal.reason。
|
|
83
|
+
* @returns 技能文件绝对路径数组;目录缺失返回空数组。
|
|
84
|
+
*/
|
|
85
|
+
async function discoverSkillFiles(root, signal) {
|
|
86
|
+
let entries
|
|
87
|
+
try {
|
|
88
|
+
signal?.throwIfAborted()
|
|
89
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
90
|
+
} catch {
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
const NON_SKILL_FILES = /^(memory|readme)\.md$/i
|
|
94
|
+
const files = []
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
signal?.throwIfAborted()
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
files.push(path.join(root, entry.name, 'SKILL.md'))
|
|
99
|
+
} else if (entry.isFile() && entry.name.endsWith('.md') && !NON_SKILL_FILES.test(entry.name)) {
|
|
100
|
+
files.push(path.join(root, entry.name))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return files
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 构造 Claude 技能 SkillProvider(F12)。
|
|
108
|
+
*
|
|
109
|
+
* 归属校验采用候选对象身份(与 dsh-resume-plugin 一致):list() 为同一路径
|
|
110
|
+
* 且同一分配名复用同一候选对象,get() 拒绝任何非该身份的 candidate。
|
|
111
|
+
* 项目级技能(`<cwd>/.claude/skills`)按 options.cwd 动态并入,rank 低于
|
|
112
|
+
* 全局(同名冲突全局胜出);跨 cwd 的候选对象身份隔离(切换目录后旧候选
|
|
113
|
+
* 归属被回收,get 返回 undefined,由注册表按需失效)。
|
|
114
|
+
* @param options - `{ roots: string[], maxSkills: number }`。
|
|
115
|
+
* @returns `{ name, list, get }`;candidate.locator 为 `{ path }`。
|
|
116
|
+
*/
|
|
117
|
+
export function makeClaudeSkillsProvider({ roots, maxSkills = 30 }) {
|
|
118
|
+
// path → { candidate, assignedName, rank }:跨 list() 调用保持候选对象身份稳定。
|
|
119
|
+
const owned = new Map()
|
|
120
|
+
|
|
121
|
+
// 全集上分配 kebab 名:冲突(含字面 dup-2 撞名)追加 -2/-3 后缀。
|
|
122
|
+
const assignNames = (skills) => {
|
|
123
|
+
const assigned = new Map()
|
|
124
|
+
const taken = new Set()
|
|
125
|
+
for (const skill of skills) {
|
|
126
|
+
const base = kebabName(skill.name)
|
|
127
|
+
let finalName = base
|
|
128
|
+
let n = 2
|
|
129
|
+
while (taken.has(finalName)) finalName = `${base}-${n++}`
|
|
130
|
+
taken.add(finalName)
|
|
131
|
+
assigned.set(skill.path, finalName)
|
|
132
|
+
}
|
|
133
|
+
return assigned
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const provider = {
|
|
137
|
+
name: CLAUDE_SKILLS_PROVIDER,
|
|
138
|
+
async list(options = {}) {
|
|
139
|
+
const signal = options?.signal
|
|
140
|
+
signal?.throwIfAborted()
|
|
141
|
+
|
|
142
|
+
const allRoots = [...roots]
|
|
143
|
+
let projectRootIndex = -1
|
|
144
|
+
// 项目级技能:options.cwd 命中项目时并入(Claude Code 项目技能布局)。
|
|
145
|
+
if (typeof options?.cwd === 'string' && options.cwd.length > 0) {
|
|
146
|
+
projectRootIndex = allRoots.length
|
|
147
|
+
allRoots.push(path.join(options.cwd, '.claude', 'skills'))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 按发现来源标记项目级文件(同一文件树里既有目录束也有扁平文件,不能用路径形态判断)。
|
|
151
|
+
const files = []
|
|
152
|
+
for (let i = 0; i < allRoots.length; i++) {
|
|
153
|
+
signal?.throwIfAborted()
|
|
154
|
+
const discovered = await discoverSkillFiles(allRoots[i], signal)
|
|
155
|
+
for (const file of discovered) files.push({ file, project: i === projectRootIndex })
|
|
156
|
+
}
|
|
157
|
+
const skills = []
|
|
158
|
+
for (const entry of files) {
|
|
159
|
+
signal?.throwIfAborted()
|
|
160
|
+
const skill = await readSkillFile(entry.file)
|
|
161
|
+
if (skill) skills.push({ ...skill, project: entry.project })
|
|
162
|
+
}
|
|
163
|
+
const assigned = assignNames(skills)
|
|
164
|
+
|
|
165
|
+
const candidates = []
|
|
166
|
+
const seen = new Set()
|
|
167
|
+
for (const skill of skills) {
|
|
168
|
+
signal?.throwIfAborted()
|
|
169
|
+
const finalName = assigned.get(skill.path)
|
|
170
|
+
const rank = skill.project ? PROJECT_SKILL_RANK : GLOBAL_SKILL_RANK
|
|
171
|
+
const prev = owned.get(skill.path)
|
|
172
|
+
if (prev && prev.assignedName === finalName && prev.rank === rank) {
|
|
173
|
+
candidates.push(prev.candidate)
|
|
174
|
+
} else {
|
|
175
|
+
const candidate = {
|
|
176
|
+
name: finalName,
|
|
177
|
+
description: skill.description,
|
|
178
|
+
...(skill.argumentHint ? { whenToUse: `argument hint: ${skill.argumentHint}` } : {}),
|
|
179
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
180
|
+
provider: provider.name,
|
|
181
|
+
source: skill.project ? 'claude-project' : 'claude',
|
|
182
|
+
resourceBase: { kind: 'directory', path: path.dirname(skill.path) },
|
|
183
|
+
rank,
|
|
184
|
+
locator: { path: skill.path },
|
|
185
|
+
path: skill.path,
|
|
186
|
+
metadata: skill.meta,
|
|
187
|
+
}
|
|
188
|
+
owned.set(skill.path, { candidate, assignedName: finalName, rank })
|
|
189
|
+
candidates.push(candidate)
|
|
190
|
+
}
|
|
191
|
+
seen.add(skill.path)
|
|
192
|
+
}
|
|
193
|
+
for (const filePath of [...owned.keys()]) {
|
|
194
|
+
if (!seen.has(filePath)) owned.delete(filePath)
|
|
195
|
+
}
|
|
196
|
+
return candidates.sort((a, b) => a.name.localeCompare(b.name)).slice(0, maxSkills)
|
|
197
|
+
},
|
|
198
|
+
async get(candidate, options = {}) {
|
|
199
|
+
options?.signal?.throwIfAborted()
|
|
200
|
+
const prev = candidate?.locator?.path ? owned.get(candidate.locator.path) : undefined
|
|
201
|
+
if (!prev || prev.candidate !== candidate) return undefined
|
|
202
|
+
const skill = await readSkillFile(candidate.locator.path)
|
|
203
|
+
if (!skill) return undefined
|
|
204
|
+
return {
|
|
205
|
+
name: candidate.name,
|
|
206
|
+
description: candidate.description,
|
|
207
|
+
...(candidate.whenToUse ? { whenToUse: candidate.whenToUse } : {}),
|
|
208
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
209
|
+
provider: provider.name,
|
|
210
|
+
source: candidate.source,
|
|
211
|
+
resourceBase: candidate.resourceBase,
|
|
212
|
+
content: skill.content,
|
|
213
|
+
path: skill.path,
|
|
214
|
+
metadata: skill.meta,
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
return provider
|
|
219
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/sources/claude/mapper.mjs — Claude Code 源映射器(四合一迁移向导,纯函数)。
|
|
3
|
+
//
|
|
4
|
+
// 清单 → 迁移计划:
|
|
5
|
+
// - sessions → import-session(会话转换与落盘复用一期 convertClaudeJsonl /
|
|
6
|
+
// persistConverted,provider 'claude-code';幂等走一期 imports.json,无 digest)。
|
|
7
|
+
// - skills(一期已过滤为含 name+description)→ copy(目录束整目录复制,扁平
|
|
8
|
+
// .md 写入 SKILL.md)。
|
|
9
|
+
// - memories(projects/*/memory/*.md)→ append-section(DSH 全局 AGENTS.md 管理段,
|
|
10
|
+
// 每条一个段;一期 F11 的实时注入同时保留,两者互补:AGENTS.md 落盘可审计,
|
|
11
|
+
// 实时注入按请求刷新)。
|
|
12
|
+
// - instructions(全局 CLAUDE.md)→ append-section。
|
|
13
|
+
// - hooks(settings.json)→ unsupported 清单。
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises'
|
|
16
|
+
import { planKey } from '../contract.mjs'
|
|
17
|
+
import { defaultAgentsMdPath } from '../../agmd-section.mjs'
|
|
18
|
+
import { skillTargetPath, kebabName } from '../../skill-migrate.mjs'
|
|
19
|
+
import { hookUnsupportedPlan } from '../../commands-migrate.mjs'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 映射 Claude 检测清单为迁移计划。
|
|
23
|
+
* @param source - 'claude'。
|
|
24
|
+
* @param detection - claude/parser.mjs 的 detect() 输出。
|
|
25
|
+
* @param opts - `{ skillsDir, agentsMdPath }`。
|
|
26
|
+
* @returns `{ plans, errors }`。
|
|
27
|
+
*/
|
|
28
|
+
export async function mapSource(source, detection, opts = {}) {
|
|
29
|
+
const plans = []
|
|
30
|
+
const errors = []
|
|
31
|
+
const agentsMdPath = opts.agentsMdPath ?? defaultAgentsMdPath()
|
|
32
|
+
const skillsDir = opts.skillsDir
|
|
33
|
+
|
|
34
|
+
for (const session of detection.sessions ?? []) {
|
|
35
|
+
plans.push({
|
|
36
|
+
key: planKey(source, 'session', session.file),
|
|
37
|
+
from: source,
|
|
38
|
+
kind: 'session',
|
|
39
|
+
action: 'import-session',
|
|
40
|
+
source: { file: session.file, title: session.title, cwd: session.cwd, importKey: session.file, turns: session.turns },
|
|
41
|
+
target: {},
|
|
42
|
+
provider: 'claude-code',
|
|
43
|
+
title: session.title,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const skill of detection.skills ?? []) {
|
|
48
|
+
plans.push({
|
|
49
|
+
key: planKey(source, 'skill', skill.id),
|
|
50
|
+
from: source,
|
|
51
|
+
kind: 'skill',
|
|
52
|
+
action: 'copy',
|
|
53
|
+
source: { file: skill.file, dir: skill.dir, name: skill.name, ...(skill.flat ? { flat: true } : {}) },
|
|
54
|
+
target: skillsDir ? { path: skillTargetPath(skillsDir, kebabName(skill.name)) } : {},
|
|
55
|
+
digest: skill.digest,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const memory of detection.memories ?? []) {
|
|
60
|
+
try {
|
|
61
|
+
const content = await readFile(memory.file, 'utf8')
|
|
62
|
+
plans.push({
|
|
63
|
+
key: planKey(source, 'memory', memory.id),
|
|
64
|
+
from: source,
|
|
65
|
+
kind: 'memory',
|
|
66
|
+
action: 'append-section',
|
|
67
|
+
source: { file: memory.file, kind: memory.kind },
|
|
68
|
+
target: { path: agentsMdPath },
|
|
69
|
+
content,
|
|
70
|
+
digest: memory.digest,
|
|
71
|
+
})
|
|
72
|
+
} catch (err) {
|
|
73
|
+
errors.push(`memory:${memory.file}: ${String((err && err.message) || err)}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const instruction of detection.instructions ?? []) {
|
|
78
|
+
try {
|
|
79
|
+
const content = await readFile(instruction.file, 'utf8')
|
|
80
|
+
plans.push({
|
|
81
|
+
key: planKey(source, 'instruction', instruction.id),
|
|
82
|
+
from: source,
|
|
83
|
+
kind: 'instruction',
|
|
84
|
+
action: 'append-section',
|
|
85
|
+
source: { file: instruction.file, kind: instruction.kind },
|
|
86
|
+
target: { path: agentsMdPath },
|
|
87
|
+
content,
|
|
88
|
+
digest: instruction.digest,
|
|
89
|
+
})
|
|
90
|
+
} catch (err) {
|
|
91
|
+
errors.push(`instruction:${instruction.file}: ${String((err && err.message) || err)}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const hook of detection.hooks ?? []) {
|
|
96
|
+
plans.push(hookUnsupportedPlan(
|
|
97
|
+
source, hook.id, hook.file,
|
|
98
|
+
'Claude Code settings.json 钩子(PreToolUse 等)在 DSH 无插件级等价 seam(宿主 tools/post-execute 瀑布需 composition 级接入),未自动迁移',
|
|
99
|
+
))
|
|
100
|
+
}
|
|
101
|
+
return { plans, ...(errors.length > 0 ? { errors } : {}) }
|
|
102
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/sources/claude/parser.mjs — Claude Code 源解析器(四合一迁移向导)。
|
|
3
|
+
//
|
|
4
|
+
// 复用一期 discovery.mjs 的扫描能力(projects/*/*.jsonl、memory、skills、
|
|
5
|
+
// CLAUDE.md、settings.json),输出向导统一的 Detection 形状。只读白名单:
|
|
6
|
+
// projects 目录、skills 目录、CLAUDE.md、settings.json —— 凭据文件
|
|
7
|
+
// (~/.claude.json 等)永不读取。git 探测默认关闭(向导只关心条目,不关心
|
|
8
|
+
// 仓库状态;scanGit 开启会派生 git 子进程)。
|
|
9
|
+
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { readFile } from 'node:fs/promises'
|
|
13
|
+
import {
|
|
14
|
+
locateClaudeHome as locateClaudeRoot,
|
|
15
|
+
scanClaudeHome,
|
|
16
|
+
} from '../../discovery.mjs'
|
|
17
|
+
import { assertAllowedRead, digestText, emptyDetection, errorText, recordError } from '../contract.mjs'
|
|
18
|
+
|
|
19
|
+
export const source = 'claude'
|
|
20
|
+
|
|
21
|
+
/** Claude 数据根定位($CLAUDE_CONFIG_DIR / ~/.claude,与一期一致)。 */
|
|
22
|
+
export function locateHome(env = process.env, home = homedir()) {
|
|
23
|
+
return locateClaudeRoot(env, home)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 只读白名单:仅四类路径(projects/skills/CLAUDE.md/settings.json)。 */
|
|
27
|
+
export function whitelist(home) {
|
|
28
|
+
return [
|
|
29
|
+
path.join(home, 'projects'),
|
|
30
|
+
path.join(home, 'skills'),
|
|
31
|
+
path.join(home, 'CLAUDE.md'),
|
|
32
|
+
path.join(home, 'settings.json'),
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 从 settings.json 提取 hooks(Claude hooks → 不支持清单的输入)。
|
|
38
|
+
* @param file - settings.json 绝对路径。
|
|
39
|
+
* @returns HookItem 数组;无 hooks/解析失败返回空数组(失败记 errors)。
|
|
40
|
+
*/
|
|
41
|
+
async function scanHooks(file, detection) {
|
|
42
|
+
let raw
|
|
43
|
+
try {
|
|
44
|
+
raw = await readFile(assertAllowedRead(whitelist(detection.home), file), 'utf8')
|
|
45
|
+
} catch (err) {
|
|
46
|
+
recordError(detection, 'settings.json', err)
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
let parsed
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(raw)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
recordError(detection, 'settings.json', 'JSON 解析失败:' + errorText(err))
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
const hooks = parsed && typeof parsed === 'object' ? parsed.hooks : undefined
|
|
57
|
+
if (!hooks || typeof hooks !== 'object') return []
|
|
58
|
+
const items = []
|
|
59
|
+
for (const [event, defs] of Object.entries(hooks)) {
|
|
60
|
+
if (!Array.isArray(defs)) continue
|
|
61
|
+
for (const def of defs) {
|
|
62
|
+
const matcher = def && typeof def.matcher === 'string' ? def.matcher : undefined
|
|
63
|
+
items.push({
|
|
64
|
+
id: `${event}:${matcher ?? defs.indexOf(def)}`,
|
|
65
|
+
file,
|
|
66
|
+
kind: `claude-hook:${event}`,
|
|
67
|
+
...(matcher ? { matcher } : {}),
|
|
68
|
+
bytes: Buffer.byteLength(raw, 'utf8'),
|
|
69
|
+
digest: digestText(raw),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return items
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 扫描 Claude 数据根(复用一期 scanClaudeHome,scanGit 默认关闭)。
|
|
78
|
+
* @param home - 数据根目录。
|
|
79
|
+
* @param opts - `{ signal, scanGit }`。
|
|
80
|
+
* @returns 统一 Detection。
|
|
81
|
+
*/
|
|
82
|
+
export async function detect(home, { signal, scanGit = false } = {}) {
|
|
83
|
+
const detection = emptyDetection(source, home)
|
|
84
|
+
const { existsSync } = await import('node:fs')
|
|
85
|
+
detection.homeExists = existsSync(home)
|
|
86
|
+
if (!detection.homeExists) return detection
|
|
87
|
+
|
|
88
|
+
let index
|
|
89
|
+
try {
|
|
90
|
+
const result = await scanClaudeHome(home, {
|
|
91
|
+
scanGit,
|
|
92
|
+
...(signal ? { signal } : {}),
|
|
93
|
+
})
|
|
94
|
+
index = result.index
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (signal?.aborted) throw err
|
|
97
|
+
recordError(detection, 'scan', err)
|
|
98
|
+
return detection
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// sessions:projects/*/*.jsonl → 统一会话条目;项目级 memories 在项目层收集。
|
|
102
|
+
for (const project of index.projects ?? []) {
|
|
103
|
+
for (const session of project.sessions ?? []) {
|
|
104
|
+
if (session.error) {
|
|
105
|
+
recordError(detection, 'session:' + session.file, new Error(session.error))
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
detection.sessions.push({
|
|
109
|
+
id: session.sessionId ?? session.file,
|
|
110
|
+
file: session.file,
|
|
111
|
+
...(session.title ? { title: session.title } : {}),
|
|
112
|
+
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
113
|
+
...(session.createdAt ? { createdAt: session.createdAt } : {}),
|
|
114
|
+
...(session.lastActivity ? { lastActivity: session.lastActivity } : {}),
|
|
115
|
+
turns: session.turns ?? 0,
|
|
116
|
+
messages: session.messages ?? 0,
|
|
117
|
+
toolCalls: session.toolCalls ?? 0,
|
|
118
|
+
...(session.malformed ? { malformed: session.malformed } : {}),
|
|
119
|
+
format: 'claude-jsonl',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
// 项目级 memories(memory/*.md)→ 记忆条目(digest 在此计算,正文映射器再读)。
|
|
123
|
+
for (const memory of project.memories ?? []) {
|
|
124
|
+
let content = ''
|
|
125
|
+
try {
|
|
126
|
+
content = await readFile(assertAllowedRead(whitelist(home), memory.path), 'utf8')
|
|
127
|
+
} catch (err) {
|
|
128
|
+
recordError(detection, 'memory:' + memory.path, err)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
detection.memories.push({
|
|
132
|
+
id: memory.path,
|
|
133
|
+
file: memory.path,
|
|
134
|
+
kind: `claude-memory:${memory.type ?? 'unknown'}`,
|
|
135
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
136
|
+
digest: digestText(content),
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// skills:~/.claude/skills/** → 技能条目(一期 scanSkills 已过滤无描述文件)。
|
|
142
|
+
for (const skill of index.personal?.skills ?? []) {
|
|
143
|
+
let content = ''
|
|
144
|
+
try {
|
|
145
|
+
content = await readFile(assertAllowedRead(whitelist(home), skill.path), 'utf8')
|
|
146
|
+
} catch (err) {
|
|
147
|
+
recordError(detection, 'skill:' + skill.path, err)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
detection.skills.push({
|
|
151
|
+
id: skill.path,
|
|
152
|
+
dir: path.dirname(skill.path),
|
|
153
|
+
file: skill.path,
|
|
154
|
+
flat: path.basename(skill.path).toLowerCase() !== 'skill.md',
|
|
155
|
+
name: skill.name,
|
|
156
|
+
description: skill.description,
|
|
157
|
+
compatible: true, // 一期 readSkillFile 已要求 name+description 齐备
|
|
158
|
+
...(Number.isFinite(skill.level) ? { level: skill.level } : {}),
|
|
159
|
+
digest: digestText(content),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// instructions:全局 CLAUDE.md。
|
|
164
|
+
const globalClaudeMd = index.personal?.globalClaudeMd
|
|
165
|
+
if (globalClaudeMd) {
|
|
166
|
+
try {
|
|
167
|
+
const content = await readFile(assertAllowedRead(whitelist(home), globalClaudeMd.path), 'utf8')
|
|
168
|
+
detection.instructions.push({
|
|
169
|
+
id: globalClaudeMd.path,
|
|
170
|
+
file: globalClaudeMd.path,
|
|
171
|
+
kind: 'claude-md',
|
|
172
|
+
bytes: globalClaudeMd.sizeBytes ?? Buffer.byteLength(content, 'utf8'),
|
|
173
|
+
digest: digestText(content),
|
|
174
|
+
})
|
|
175
|
+
} catch (err) {
|
|
176
|
+
recordError(detection, 'CLAUDE.md', err)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// hooks:全局 + 项目级 settings.json 的 hooks 段 → 不支持清单输入。
|
|
181
|
+
if (index.personal?.settings) {
|
|
182
|
+
detection.hooks.push(...await scanHooks(index.personal.settings.path, detection))
|
|
183
|
+
}
|
|
184
|
+
for (const project of index.projects ?? []) {
|
|
185
|
+
if (project.projectSettings) {
|
|
186
|
+
detection.hooks.push(...await scanHooks(project.projectSettings.path, detection))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return detection
|
|
190
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/sources/codex/mapper.mjs — Codex CLI 源映射器(四合一迁移向导,零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 清单 → 迁移计划:
|
|
5
|
+
// - sessions → import-session(会话转换与落盘复用一期 convertCodexJsonl /
|
|
6
|
+
// persistConverted,provider 'codex';幂等走一期 imports,无 digest)。
|
|
7
|
+
// - skills → copy(兼容)/ convert-copy(缺 name/description 补合成 frontmatter)。
|
|
8
|
+
// - memories(memories/*.md)与 instructions(AGENTS.md/CODEX.md)→ append-section
|
|
9
|
+
// (DSH 全局 AGENTS.md 管理段,每条一个独立段 key)。
|
|
10
|
+
// - commands(hooks/*/command.md)→ 纯提示词 register-command、含 shell unsupported。
|
|
11
|
+
// - hooks(hooks/*/prompt.md、config.toml [commands])→ unsupported 清单。
|
|
12
|
+
// 正文(memories/instructions/commands)在本模块读一次;所有读取经 assertAllowedRead
|
|
13
|
+
// 守卫(复用 parser 的白名单),越界/缺文件跳过并记 errors,绝不越界读隐私文件。
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises'
|
|
16
|
+
import { assertAllowedRead, planKey } from '../contract.mjs'
|
|
17
|
+
import { skillTargetPath, kebabName } from '../../skill-migrate.mjs'
|
|
18
|
+
import { commandPlan, hookUnsupportedPlan } from '../../commands-migrate.mjs'
|
|
19
|
+
import { defaultAgentsMdPath } from '../../agmd-section.mjs'
|
|
20
|
+
import { whitelist } from './parser.mjs'
|
|
21
|
+
|
|
22
|
+
/** 钩子 → 不支持清单的固定文案。 */
|
|
23
|
+
const CODEX_HOOK_REASON = 'Codex 钩子(matched-tool/shell)在 DSH 无插件级等价 seam,未自动迁移'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 映射 Codex 检测清单为迁移计划。
|
|
27
|
+
* @param source - 'codex'。
|
|
28
|
+
* @param detection - codex/parser.mjs 的 detect() 输出。
|
|
29
|
+
* @param opts - `{ skillsDir, agentsMdPath }`;agentsMdPath 缺省 defaultAgentsMdPath()。
|
|
30
|
+
* @returns `{ plans }`;读取失败时附 `{ errors }`(plans 数组仍返回)。
|
|
31
|
+
*/
|
|
32
|
+
export async function mapSource(source, detection, opts = {}) {
|
|
33
|
+
const plans = []
|
|
34
|
+
const errors = []
|
|
35
|
+
const agentsMdPath = opts.agentsMdPath ?? defaultAgentsMdPath()
|
|
36
|
+
const skillsDir = opts.skillsDir
|
|
37
|
+
const roots = whitelist(detection.home)
|
|
38
|
+
|
|
39
|
+
const read = async (file, scope) => {
|
|
40
|
+
try {
|
|
41
|
+
return await readFile(assertAllowedRead(roots, file), 'utf8')
|
|
42
|
+
} catch (err) {
|
|
43
|
+
errors.push(`${scope}:${String((err && err.message) || err)}`)
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const session of detection.sessions ?? []) {
|
|
49
|
+
plans.push({
|
|
50
|
+
key: planKey(source, 'session', session.file),
|
|
51
|
+
from: source,
|
|
52
|
+
kind: 'session',
|
|
53
|
+
action: 'import-session',
|
|
54
|
+
source: { file: session.file, title: session.title, cwd: session.cwd, importKey: session.file, turns: session.turns },
|
|
55
|
+
target: {},
|
|
56
|
+
provider: 'codex',
|
|
57
|
+
title: session.title,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const skill of detection.skills ?? []) {
|
|
62
|
+
plans.push({
|
|
63
|
+
key: planKey(source, 'skill', skill.id),
|
|
64
|
+
from: source,
|
|
65
|
+
kind: 'skill',
|
|
66
|
+
action: skill.compatible ? 'copy' : 'convert-copy',
|
|
67
|
+
source: { file: skill.file, dir: skill.dir, name: skill.name },
|
|
68
|
+
target: skillsDir ? { path: skillTargetPath(skillsDir, kebabName(skill.name)) } : {},
|
|
69
|
+
digest: skill.digest,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const memory of detection.memories ?? []) {
|
|
74
|
+
const content = await read(memory.file, 'memory:' + memory.file)
|
|
75
|
+
if (content === null) continue
|
|
76
|
+
plans.push({
|
|
77
|
+
key: planKey(source, 'memory', memory.id),
|
|
78
|
+
from: source,
|
|
79
|
+
kind: 'memory',
|
|
80
|
+
action: 'append-section',
|
|
81
|
+
source: { file: memory.file },
|
|
82
|
+
target: { path: agentsMdPath },
|
|
83
|
+
content,
|
|
84
|
+
digest: memory.digest,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const instruction of detection.instructions ?? []) {
|
|
89
|
+
const content = await read(instruction.file, 'instruction:' + instruction.file)
|
|
90
|
+
if (content === null) continue
|
|
91
|
+
plans.push({
|
|
92
|
+
key: planKey(source, 'instruction', instruction.id),
|
|
93
|
+
from: source,
|
|
94
|
+
kind: 'instruction',
|
|
95
|
+
action: 'append-section',
|
|
96
|
+
source: { file: instruction.file },
|
|
97
|
+
target: { path: agentsMdPath },
|
|
98
|
+
content,
|
|
99
|
+
digest: instruction.digest,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const command of detection.commands ?? []) {
|
|
104
|
+
const content = await read(command.file, 'command:' + command.file)
|
|
105
|
+
if (content === null) continue
|
|
106
|
+
plans.push(commandPlan(source, 'command', command.id, {
|
|
107
|
+
file: command.file,
|
|
108
|
+
name: command.name,
|
|
109
|
+
promptOnly: command.promptOnly,
|
|
110
|
+
prompt: content,
|
|
111
|
+
digest: command.digest,
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const hook of detection.hooks ?? []) {
|
|
116
|
+
plans.push(hookUnsupportedPlan(source, hook.id, hook.file, CODEX_HOOK_REASON))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { plans, ...(errors.length > 0 ? { errors } : {}) }
|
|
120
|
+
}
|