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
package/lib/handoff.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/handoff.mjs — 续聊交接摘要(F17,零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 安全模型沿用 Demogorgon314/dsh-resume-plugin(MIT,见 THIRD_PARTY_NOTICES.md):
|
|
5
|
+
// 外部 transcript 是不可信惰性历史——摘要只转述目标、涉及文件与停止点;
|
|
6
|
+
// system/developer 记录不参与(convert 已忽略),reasoning/thinking 内容不进入
|
|
7
|
+
// 摘要;旧工具输出视为过期证据,继续前必须复核仓库现状。摘要按字符数截断。
|
|
8
|
+
|
|
9
|
+
/** 默认交接摘要字符上限(F17 可配)。 */
|
|
10
|
+
export const DEFAULT_HANDOFF_MAX_CHARS = 2048
|
|
11
|
+
|
|
12
|
+
/** 已知 Claude 记录类型:未知类型计入摘要警告(宽容跳过不失败)。 */
|
|
13
|
+
export const KNOWN_CLAUDE_TYPES = new Set([
|
|
14
|
+
'user', 'assistant', 'system', 'summary', 'custom-title', 'ai-title',
|
|
15
|
+
'content-replacement', 'progress', 'file-history-snapshot', 'file-history-delta',
|
|
16
|
+
'attribution-snapshot', 'queue-operation', 'last-prompt', 'tag', 'agent-name',
|
|
17
|
+
'agent-color', 'agent-setting', 'mode', 'worktree-state',
|
|
18
|
+
'context-collapse-commit', 'context-collapse-snapshot',
|
|
19
|
+
'permission', 'permission-mode', 'attachment',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/** 可能携带文件/命令引用的工具名(只取参数做参考,不执行任何内容)。 */
|
|
23
|
+
const FILE_TOOLS = new Set([
|
|
24
|
+
'Read', 'Edit', 'Write', 'NotebookEdit', 'MultiEdit', 'Glob', 'Grep',
|
|
25
|
+
'Bash', 'LS', 'WebFetch', 'WebSearch', 'Task', 'TodoWrite',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 单行归一 + 截断(截断后再去尾空格)。
|
|
30
|
+
* @param text - 原始文本。
|
|
31
|
+
* @param limit - 码点上限。
|
|
32
|
+
* @returns 归一化文本。
|
|
33
|
+
*/
|
|
34
|
+
export function oneLine(text, limit) {
|
|
35
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, limit).trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 提取一步的纯文本(跳过 reasoning/tool-call 块)。
|
|
40
|
+
* @param step - convert 输出的 step。
|
|
41
|
+
* @returns 文本。
|
|
42
|
+
*/
|
|
43
|
+
export function textOfStep(step) {
|
|
44
|
+
return (step.content ?? [])
|
|
45
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
46
|
+
.map((b) => b.text)
|
|
47
|
+
.join('\n')
|
|
48
|
+
.trim()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 从工具调用参数中提取文件/命令引用(只读字符串,不执行)。
|
|
53
|
+
* @param toolCall - convert 输出的 toolCall。
|
|
54
|
+
* @returns 引用字符串或 null。
|
|
55
|
+
*/
|
|
56
|
+
export function fileArgOf(toolCall) {
|
|
57
|
+
if (!toolCall || !FILE_TOOLS.has(toolCall.name)) return null
|
|
58
|
+
let args = {}
|
|
59
|
+
try {
|
|
60
|
+
args = JSON.parse(toolCall.arguments ?? '{}')
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
if (args === null || typeof args !== 'object') return null
|
|
65
|
+
const candidate = args.file_path ?? args.path ?? args.pattern ?? args.command ?? args.query
|
|
66
|
+
return typeof candidate === 'string' && candidate.length > 0 && candidate.length < 300 ? candidate : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 生成交接摘要(F17):目标、最后请求、涉及文件、规模、停止点、警告。
|
|
71
|
+
* @param converted - convertClaudeJsonl 输出。
|
|
72
|
+
* @param options - `{ maxChars, title, sourceCwd, workspaceCwd }`;sourceCwd
|
|
73
|
+
* 为源 Claude 项目目录,workspaceCwd 为 claudecode 模式下会话实际所在目录。
|
|
74
|
+
* @returns 摘要文本。
|
|
75
|
+
*/
|
|
76
|
+
export function buildHandoff(converted, { maxChars = DEFAULT_HANDOFF_MAX_CHARS, title = '', sourceCwd, workspaceCwd } = {}) {
|
|
77
|
+
const turns = converted.turns ?? []
|
|
78
|
+
const lines = []
|
|
79
|
+
lines.push('# Claude Code 会话交接(静态历史,仅参考)')
|
|
80
|
+
if (title) lines.push(`源会话:${oneLine(title, 200)}`)
|
|
81
|
+
if (typeof sourceCwd === 'string' && sourceCwd.length > 0) {
|
|
82
|
+
lines.push(`源项目目录:${oneLine(sourceCwd, 500)}`)
|
|
83
|
+
}
|
|
84
|
+
if (typeof workspaceCwd === 'string' && workspaceCwd.length > 0) {
|
|
85
|
+
lines.push(`本会话工作目录(claudecode 工作区):${oneLine(workspaceCwd, 500)}(继续前请复核源项目当前状态)`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const goals = turns.slice(0, 3).map((t) => oneLine(t.prompt, 200)).filter(Boolean)
|
|
89
|
+
if (goals.length > 0) lines.push('早期目标:' + goals.join(' | '))
|
|
90
|
+
|
|
91
|
+
const lastTurn = turns.at(-1)
|
|
92
|
+
if (lastTurn) lines.push('最后用户请求:' + oneLine(lastTurn.prompt, 400))
|
|
93
|
+
|
|
94
|
+
const refs = new Set()
|
|
95
|
+
for (const turn of turns.slice(-8)) {
|
|
96
|
+
for (const step of turn.steps ?? []) {
|
|
97
|
+
for (const toolCall of step.toolCalls ?? []) {
|
|
98
|
+
const arg = fileArgOf(toolCall)
|
|
99
|
+
if (arg) refs.add(arg)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (refs.size > 0) {
|
|
104
|
+
lines.push('最近涉及的文件/命令:\n- ' + [...refs].slice(0, 10).join('\n- '))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lines.push(`规模:${turns.length} 轮、${converted.messages ?? 0} 条消息、${converted.toolCalls ?? 0} 次工具调用`)
|
|
108
|
+
|
|
109
|
+
const lastText = turns.flatMap((t) => t.steps ?? []).map(textOfStep).filter(Boolean).at(-1)
|
|
110
|
+
if (lastText) lines.push('最后助手输出(截断):' + oneLine(lastText, 600))
|
|
111
|
+
|
|
112
|
+
const warnings = []
|
|
113
|
+
if ((converted.skipped ?? 0) > 0) warnings.push(`${converted.skipped} 行畸形记录被跳过`)
|
|
114
|
+
const synthesized = converted.repaired?.synthesized ?? 0
|
|
115
|
+
if (synthesized > 0) {
|
|
116
|
+
warnings.push(`${synthesized} 处被中断的工具调用已补为合成错误结果(无真实返回记录)`)
|
|
117
|
+
}
|
|
118
|
+
const summaryCount = converted.typeCounts?.summary ?? 0
|
|
119
|
+
if (summaryCount > 0) {
|
|
120
|
+
// D1 调研结论:Claude summary 记录未映射为 DSH compaction 节点(合成需要
|
|
121
|
+
// 伪造 compaction 事务的 seq 范围/检查点消息,触发平台 invariant 的风险
|
|
122
|
+
// 大于收益);完整历史以原始轮次导入,摘要仅在此提示。
|
|
123
|
+
warnings.push(`源记录含 ${summaryCount} 条 summary(Claude 上下文压缩摘要,未映射为压缩节点)`)
|
|
124
|
+
}
|
|
125
|
+
const unknown = Object.entries(converted.typeCounts ?? {})
|
|
126
|
+
.filter(([type]) => !KNOWN_CLAUDE_TYPES.has(type))
|
|
127
|
+
if (unknown.length > 0) {
|
|
128
|
+
warnings.push('未知记录类型:' + unknown.map(([type, n]) => `${type}×${n}`).join('、'))
|
|
129
|
+
}
|
|
130
|
+
if (warnings.length > 0) lines.push('警告:' + warnings.join(';'))
|
|
131
|
+
|
|
132
|
+
lines.push('以上是静态历史。继续前请复核当前仓库状态(分支/diff/关键文件),旧工具输出视为过期证据。')
|
|
133
|
+
|
|
134
|
+
const full = lines.join('\n')
|
|
135
|
+
return full.length <= maxChars ? full : full.slice(0, maxChars - 1) + '…'
|
|
136
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/imports-store.mjs — imports.json 的并发安全存取与源文件级 in-flight 锁(零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 导入可以由模型工具(import_claude)、命令(claude-import-all)与面板 job
|
|
5
|
+
// 并发触发;imports.json 是单一文件,读-改-写必须串行化,同一源文件并发
|
|
6
|
+
// 导入必须互斥(后者等待前者落盘后按幂等路径复用结果,避免 create duplicate
|
|
7
|
+
// 与映射互相覆盖)。写走 discovery 的原子写(tmp + rename),并发读方不会
|
|
8
|
+
// 读到半截 JSON。
|
|
9
|
+
|
|
10
|
+
import { loadImports, saveImports, resolveCacheDir } from './discovery.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* imports.json 的串行读-改-写存取器 + 源文件级互斥锁。
|
|
14
|
+
* @returns `{ update, exclusive }`。
|
|
15
|
+
*/
|
|
16
|
+
export function createImportsStore() {
|
|
17
|
+
let writeChain = Promise.resolve()
|
|
18
|
+
const sourceLocks = new Map()
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
/**
|
|
22
|
+
* 串行执行「读取 → mutator 就地修改 → 原子写回」。多个并发调用按提交
|
|
23
|
+
* 顺序依次落地,绝不互相覆盖;单次失败不影响后续写入。
|
|
24
|
+
* @param mutator - `(imports) => result`,就地修改 imports 并返回结果。
|
|
25
|
+
* @returns 拒绝时抛出该次错误(调用方决定处理);链上后续写不受影响。
|
|
26
|
+
*/
|
|
27
|
+
update(mutator) {
|
|
28
|
+
const next = writeChain.then(async () => {
|
|
29
|
+
const imports = await loadImports(resolveCacheDir())
|
|
30
|
+
const result = await mutator(imports)
|
|
31
|
+
await saveImports(resolveCacheDir(), imports)
|
|
32
|
+
return result
|
|
33
|
+
})
|
|
34
|
+
// 队列吞掉失败:单次失败不阻塞/毒化后续写入(错误已随 next 抛给本次调用方)。
|
|
35
|
+
writeChain = next.catch(() => {})
|
|
36
|
+
return next
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 同一源文件互斥:并发调用同一 key 时按到达顺序排队,后到者等待先行者
|
|
41
|
+
* (含失败)完成后重新执行 fn——此时 imports.json 已含先行者记录,fn
|
|
42
|
+
* 内部按幂等路径处理。排队期间的新到达者挂在最新任务后,绝不并行。
|
|
43
|
+
* @param key - 互斥键(源 transcript 绝对路径)。
|
|
44
|
+
* @param fn - 独占执行的任务。
|
|
45
|
+
* @returns fn 的结果。
|
|
46
|
+
*/
|
|
47
|
+
async exclusive(key, fn) {
|
|
48
|
+
const prev = sourceLocks.get(key)
|
|
49
|
+
const task = (async () => {
|
|
50
|
+
if (prev !== undefined) await prev.catch(() => {})
|
|
51
|
+
return fn()
|
|
52
|
+
})()
|
|
53
|
+
sourceLocks.set(key, task)
|
|
54
|
+
try {
|
|
55
|
+
return await task
|
|
56
|
+
} finally {
|
|
57
|
+
if (sourceLocks.get(key) === task) sourceLocks.delete(key)
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 插件进程内的默认实例(index.mjs 与测试可各持实例,互不影响)。 */
|
|
64
|
+
export const importsStore = createImportsStore()
|
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/manifest.mjs — move.json 迁移清单的并发安全存取(零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 幂等基础:每个已执行计划记录 { digest, target, appliedAt, action },
|
|
5
|
+
// 重跑时摘要未变即跳过;force 重应用。写入串行化 + 原子写(tmp + rename),
|
|
6
|
+
// 与 imports.json(会话级幂等)并存:会话走一期 imports 机制,文件类走本清单。
|
|
7
|
+
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { resolveCacheDir } from './discovery.mjs'
|
|
11
|
+
|
|
12
|
+
/** 清单文件路径(与一期缓存同目录,`$DSH_HOME/claude-move/move.json`)。 */
|
|
13
|
+
export function manifestPath(cacheDir = resolveCacheDir()) {
|
|
14
|
+
return path.join(cacheDir, 'move.json')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 原子写 JSON(tmp + rename;目录缺失先建)。 */
|
|
18
|
+
export async function atomicWriteJson(file, value) {
|
|
19
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
20
|
+
const tmp = file + '.tmp-' + process.pid + '-' + Date.now()
|
|
21
|
+
await writeFile(tmp, JSON.stringify(value, null, 2), 'utf8')
|
|
22
|
+
await rename(tmp, file)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取清单;缺失/损坏返回空对象(损坏不抛,重跑按全新清单处理)。
|
|
27
|
+
* @param cacheDir - 缓存目录。
|
|
28
|
+
* @returns 记录映射 `{ key: { digest, target, appliedAt, action } }`。
|
|
29
|
+
*/
|
|
30
|
+
export async function loadManifest(cacheDir = resolveCacheDir()) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(manifestPath(cacheDir), 'utf8')
|
|
33
|
+
const parsed = JSON.parse(raw)
|
|
34
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
35
|
+
} catch {
|
|
36
|
+
// 缺失/损坏:空清单。
|
|
37
|
+
}
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 清单串行读-改-写存取器(与 imports-store 同构)。
|
|
43
|
+
* `cacheDir` 每次操作时惰性解析(默认值在调用时求值):进程级单例
|
|
44
|
+
* 不得在 import 时绑定 DSH_HOME,否则测试/临时 profile 的覆盖会写进默认家目录。
|
|
45
|
+
* @param cacheDir - 缓存目录;缺省时每次调用经 resolveCacheDir() 惰性解析。
|
|
46
|
+
* @returns `{ update, load }`。
|
|
47
|
+
*/
|
|
48
|
+
export function createManifestStore(cacheDir) {
|
|
49
|
+
let writeChain = Promise.resolve()
|
|
50
|
+
return {
|
|
51
|
+
load: () => loadManifest(cacheDir ?? resolveCacheDir()),
|
|
52
|
+
/** 串行「读取 → mutator 就地修改 → 原子写回」;单次失败不毒化后续写入。 */
|
|
53
|
+
update(mutator) {
|
|
54
|
+
const dir = cacheDir ?? resolveCacheDir()
|
|
55
|
+
const next = writeChain.then(async () => {
|
|
56
|
+
const manifest = await loadManifest(dir)
|
|
57
|
+
const result = await mutator(manifest)
|
|
58
|
+
await atomicWriteJson(manifestPath(dir), manifest)
|
|
59
|
+
return result
|
|
60
|
+
})
|
|
61
|
+
writeChain = next.catch(() => {})
|
|
62
|
+
return next
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 记录一条已执行计划。 */
|
|
68
|
+
export function recordEntry(manifest, key, record) {
|
|
69
|
+
manifest[key] = { appliedAt: new Date().toISOString(), ...record }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 默认进程内实例(index.mjs 与测试可各持实例)。 */
|
|
73
|
+
export const manifestStore = createManifestStore()
|
package/lib/persona.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/persona.mjs — 迁移向导生成的短 persona 段落(零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 对齐官方 Minimal persona 风格:一句简短角色陈述开头、保持短小
|
|
5
|
+
// (`You are a helpful software engineer assistant.`)。迁移报告里给用户的
|
|
6
|
+
// persona 建议与向导注入文案都从这里生成,绝不超过两三句话。
|
|
7
|
+
|
|
8
|
+
/** 常见角色的中文名(保持短小)。 */
|
|
9
|
+
const ZH_ROLES = { assistant: '助手', migration: '迁移', code: '编程', review: '审查' }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 一句角色陈述(官方 Minimal persona 风格)。
|
|
13
|
+
* @param role - 角色名(默认 'assistant')。
|
|
14
|
+
* @param lang - 'en' | 'zh'。
|
|
15
|
+
* @returns 单句 persona。
|
|
16
|
+
*/
|
|
17
|
+
export function personaSentence(role = 'assistant', lang = 'en') {
|
|
18
|
+
const raw = String(role ?? 'assistant').trim() || 'assistant'
|
|
19
|
+
const name = lang === 'zh' ? (ZH_ROLES[raw] ?? raw) : raw
|
|
20
|
+
return lang === 'zh'
|
|
21
|
+
? `你是一名高效的${name}助手。`
|
|
22
|
+
: `You are a helpful ${raw} assistant.`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 迁移向导的短 persona 段落:一句角色陈述 + 一句边界说明,保持短小。
|
|
27
|
+
* 用于(a)报告中的 persona 建议;(b)OpenCode agent 转技能时的 persona 建议。
|
|
28
|
+
* @param role - 角色名。
|
|
29
|
+
* @param lang - 'en' | 'zh'。
|
|
30
|
+
* @returns 不超过两句的段落。
|
|
31
|
+
*/
|
|
32
|
+
export function personaParagraph(role = 'migration', lang = 'en') {
|
|
33
|
+
const sentence = personaSentence(role, lang)
|
|
34
|
+
return lang === 'zh'
|
|
35
|
+
? `${sentence}你只负责迁移本机数据到 DeepSeek Harness,回答简短、先确认再执行。`
|
|
36
|
+
: `${sentence} You migrate local agent data into DeepSeek Harness: answer briefly and confirm before executing.`
|
|
37
|
+
}
|
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/report.mjs — 导入报告辅助(零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// S4:疑似密钥/凭据只报告「文件:行:类型」,绝不展示匹配内容本身。
|
|
5
|
+
|
|
6
|
+
/** 疑似凭据片段模式:kind 标签 + 匹配规则。顺序敏感:更具体的前缀在前,避免重叠计数。 */
|
|
7
|
+
export const SECRET_PATTERNS = Object.freeze([
|
|
8
|
+
{ kind: 'aws-access-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
9
|
+
{ kind: 'github-token', pattern: /\bgh[pousr]_[0-9A-Za-z]{36,255}\b|\bgithub_pat_[0-9A-Za-z_]{22,255}\b/g },
|
|
10
|
+
{ kind: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_\-]{35}\b/g },
|
|
11
|
+
{ kind: 'anthropic-key', pattern: /\bsk-ant-[0-9A-Za-z\-_]{20,}\b/g },
|
|
12
|
+
{ kind: 'openai-key', pattern: /\bsk-(?!ant-)[0-9A-Za-z\-_]{20,}\b/g },
|
|
13
|
+
{ kind: 'private-key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g },
|
|
14
|
+
{ kind: 'slack-token', pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}\b/g },
|
|
15
|
+
{ kind: 'generic-bearer', pattern: /(?:"|')?bearer\s+[0-9A-Za-z\-._~+/]{20,}(?:"|')?/gi },
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 扫描原始文本中的疑似凭据片段(S4)。
|
|
20
|
+
* @param text - 原始文本(如 transcript 全文)。
|
|
21
|
+
* @param maxHits - 上报上限,超出截断(完整计数仍保留在 total)。
|
|
22
|
+
* @returns `{ total, hits: [{ line, kind }] }`;无命中返回 `{ total: 0, hits: [] }`。
|
|
23
|
+
*/
|
|
24
|
+
export function scanSecrets(text, maxHits = 50) {
|
|
25
|
+
const lines = String(text).split('\n')
|
|
26
|
+
const hits = []
|
|
27
|
+
let total = 0
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
for (const { kind, pattern } of SECRET_PATTERNS) {
|
|
30
|
+
pattern.lastIndex = 0
|
|
31
|
+
if (pattern.test(lines[i])) {
|
|
32
|
+
total++
|
|
33
|
+
if (hits.length < maxHits) hits.push({ line: i + 1, kind })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { total, hits }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 权限类记录(S5):默认不导入,只在报告中统计。 */
|
|
41
|
+
export const PERMISSION_RECORD_TYPES = Object.freeze([
|
|
42
|
+
'permission',
|
|
43
|
+
'permission-mode',
|
|
44
|
+
'queue-operation',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 从 convert 的 typeCounts 汇总权限类记录数量(S5)。
|
|
49
|
+
* @param typeCounts - convert 输出里的 typeCounts。
|
|
50
|
+
* @returns `{ byType, total }`。
|
|
51
|
+
*/
|
|
52
|
+
export function summarizePermissions(typeCounts) {
|
|
53
|
+
const byType = {}
|
|
54
|
+
let total = 0
|
|
55
|
+
for (const type of PERMISSION_RECORD_TYPES) {
|
|
56
|
+
const count = typeCounts?.[type] ?? 0
|
|
57
|
+
if (count > 0) {
|
|
58
|
+
byType[type] = count
|
|
59
|
+
total += count
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { byType, total }
|
|
63
|
+
}
|
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/settings.mjs — Claude Code settings.json 翻译(F14,零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 不直接搬运(DSH 配置格式不兼容),而是解析 allow/deny/ask 权限规则与
|
|
5
|
+
// model 偏好,生成 DSH 配置建议文本;无法映射的键显式列出,绝不静默丢弃。
|
|
6
|
+
// 建议里的 cordis.yml 片段是示意(DSH 权限面为 permission presets + approval
|
|
7
|
+
// 策略,具体生效位置以官方 docs/permission-presets 为准)。
|
|
8
|
+
|
|
9
|
+
/** 已识别的 Claude settings.json 顶层键(其余计入 unmapped)。 */
|
|
10
|
+
export const KNOWN_SETTINGS_KEYS = Object.freeze([
|
|
11
|
+
'permissions', 'model', 'env', 'hooks', 'apiKeyHelper', 'includeCoAuthoredBy',
|
|
12
|
+
'forceLoginMethod', 'cleanupPeriodDays', 'spinnerTipsEnabled',
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 解析 settings.json 文本。
|
|
17
|
+
* @param raw - 原始 JSON 文本。
|
|
18
|
+
* @returns 解析结果;坏 JSON 返回 `{ error }`。
|
|
19
|
+
*/
|
|
20
|
+
export function parseClaudeSettings(raw) {
|
|
21
|
+
let parsed
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(raw)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { error: String((err && err.message) || err) }
|
|
26
|
+
}
|
|
27
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
28
|
+
return { error: 'settings.json 根节点不是 JSON 对象' }
|
|
29
|
+
}
|
|
30
|
+
return { parsed }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 把一条 Claude 权限规则翻译为 DSH 建议。规则形如 `Read(./path)`、
|
|
35
|
+
* `Edit(~/.ssh/**)`、`Bash(npm run *)`、`WebFetch(domain:github.com)`。
|
|
36
|
+
* @param rule - 规则字符串。
|
|
37
|
+
* @param action - allow | deny | ask。
|
|
38
|
+
* @returns `{ title, tool, detail }` 或 null(无法识别工具域)。
|
|
39
|
+
*/
|
|
40
|
+
export function translatePermissionRule(rule, action) {
|
|
41
|
+
const match = /^([A-Za-z]+)\s*\((.+)\)$/.exec(String(rule).trim())
|
|
42
|
+
if (!match) return { title: `无法识别的规则:${rule}`, tool: 'unknown', detail: `保留原文待人工确认(${action})。` }
|
|
43
|
+
const [, tool, target] = match
|
|
44
|
+
const actionLabel = { allow: '允许', deny: '拒绝', ask: '询问' }[action] ?? action
|
|
45
|
+
return {
|
|
46
|
+
title: `${actionLabel} ${tool}(${target})`,
|
|
47
|
+
tool,
|
|
48
|
+
target,
|
|
49
|
+
detail: '',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 翻译整个 settings.json(F14)。
|
|
55
|
+
* @param raw - settings.json 原始文本。
|
|
56
|
+
* @param location - 报告里显示的来源位置。
|
|
57
|
+
* @returns `{ suggestions, unmapped }`;解析失败返回 `{ error, unmapped: [] }`。
|
|
58
|
+
*/
|
|
59
|
+
export function translateSettings(raw, location) {
|
|
60
|
+
const { parsed, error } = parseClaudeSettings(raw)
|
|
61
|
+
if (error) return { error: `${location}: ${error}`, unmapped: [] }
|
|
62
|
+
|
|
63
|
+
const suggestions = []
|
|
64
|
+
const unmapped = []
|
|
65
|
+
|
|
66
|
+
const permissions = parsed.permissions
|
|
67
|
+
if (permissions && typeof permissions === 'object' && !Array.isArray(permissions)) {
|
|
68
|
+
for (const action of ['deny', 'ask', 'allow']) {
|
|
69
|
+
const rules = permissions[action]
|
|
70
|
+
if (!Array.isArray(rules)) continue
|
|
71
|
+
for (const rule of rules) {
|
|
72
|
+
const translated = translatePermissionRule(rule, action)
|
|
73
|
+
if (translated) suggestions.push({ kind: 'permission', action, ...translated })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(permissions.additionalDirectories)) {
|
|
77
|
+
suggestions.push({
|
|
78
|
+
kind: 'additional-directories',
|
|
79
|
+
action: 'allow',
|
|
80
|
+
title: `额外工作目录 ${permissions.additionalDirectories.length} 个`,
|
|
81
|
+
tool: 'directory',
|
|
82
|
+
target: permissions.additionalDirectories.join(', '),
|
|
83
|
+
detail: 'DSH 按工作区授权目录访问;在 DSH 中为这些目录创建工作区即可。',
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
for (const key of Object.keys(permissions)) {
|
|
87
|
+
if (!['allow', 'deny', 'ask', 'additionalDirectories', 'defaultMode'].includes(key)) {
|
|
88
|
+
unmapped.push(`permissions.${key}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof parsed.model === 'string' && parsed.model.length > 0) {
|
|
94
|
+
suggestions.push({
|
|
95
|
+
kind: 'model',
|
|
96
|
+
action: 'prefer',
|
|
97
|
+
title: `默认模型偏好:${parsed.model}`,
|
|
98
|
+
tool: 'model',
|
|
99
|
+
target: parsed.model,
|
|
100
|
+
detail: '在 DSH Settings → Models 中选择对应模型;DSH 不读取 Claude 的模型 id。',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (typeof parsed.apiKeyHelper === 'string' && parsed.apiKeyHelper.length > 0) {
|
|
105
|
+
suggestions.push({
|
|
106
|
+
kind: 'api-key-helper',
|
|
107
|
+
action: 'prefer',
|
|
108
|
+
title: `apiKeyHelper:${parsed.apiKeyHelper}`,
|
|
109
|
+
tool: 'credential',
|
|
110
|
+
target: parsed.apiKeyHelper,
|
|
111
|
+
detail: 'DSH 用 Settings → Models 或凭据服务管理密钥;不执行外部脚本获取密钥。',
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const key of Object.keys(parsed)) {
|
|
116
|
+
if (!KNOWN_SETTINGS_KEYS.includes(key)) unmapped.push(key)
|
|
117
|
+
}
|
|
118
|
+
if (parsed.env && typeof parsed.env === 'object') {
|
|
119
|
+
unmapped.push('env(环境变量不自动搬运,请在 DSH 会话里显式设置)')
|
|
120
|
+
}
|
|
121
|
+
if (parsed.hooks && typeof parsed.hooks === 'object') {
|
|
122
|
+
unmapped.push('hooks(Claude hooks 不自动搬运;DSH 对应机制为 hooks 桥接插件)')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { suggestions, unmapped, location }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 汇总一个或多个 settings.json 的翻译结果。
|
|
130
|
+
* @param files - `[{ location, raw }]`。
|
|
131
|
+
* @returns `{ suggestions, unmapped, errors }`。
|
|
132
|
+
*/
|
|
133
|
+
export function translateSettingsBatch(files) {
|
|
134
|
+
const suggestions = []
|
|
135
|
+
const unmapped = []
|
|
136
|
+
const errors = []
|
|
137
|
+
for (const { location, raw } of files) {
|
|
138
|
+
const result = translateSettings(raw, location)
|
|
139
|
+
if (result.error) {
|
|
140
|
+
errors.push(result.error)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
suggestions.push(...result.suggestions)
|
|
144
|
+
unmapped.push(...result.unmapped)
|
|
145
|
+
}
|
|
146
|
+
return { suggestions, unmapped, errors }
|
|
147
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/skill-migrate.mjs — 四源技能 → DSH 技能目录的兼容判定与转换(零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// DSH 技能落盘约定($DSH_HOME/skills,官方 skill-filesystem 契约):
|
|
5
|
+
// - 目录束 `<name>/SKILL.md` 或扁平 `<name>.md`;
|
|
6
|
+
// - SKILL.md 必须有 YAML frontmatter,且 name 与 description 均非空,
|
|
7
|
+
// 否则该文件被跳过(甚至拖垮整目录加载,见 issue#1);
|
|
8
|
+
// - 技能名 kebab-case([a-z0-9]+(-[a-z0-9]+)*)。
|
|
9
|
+
//
|
|
10
|
+
// 迁移规则:源 SKILL.md frontmatter 含非空 name+description → 兼容,内容直拷
|
|
11
|
+
// (只改落点目录名);否则合成最小 frontmatter 转换(name 取 frontmatter/目录名
|
|
12
|
+
// kebab 化,description 取 frontmatter 或正文首个标题/首行,截断),正文原样保留。
|
|
13
|
+
// README.md / MEMORY.md / 隐藏目录(.system/.hub/.git)一律跳过。
|
|
14
|
+
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { parseFrontmatter } from './frontmatter.mjs'
|
|
17
|
+
|
|
18
|
+
/** DSH 技能名 kebab-case 模式(与官方契约一致)。 */
|
|
19
|
+
export const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
20
|
+
|
|
21
|
+
/** 非技能文档/内部目录(发现与复制都跳过)。 */
|
|
22
|
+
export const SKIP_DIR_RE = /^\./ // .system/.hub/.git 等隐藏目录
|
|
23
|
+
export const SKIP_FILE_RE = /^(readme|memory)\.md$/i
|
|
24
|
+
|
|
25
|
+
/** 技能描述默认上限(合成时截断)。 */
|
|
26
|
+
export const DESCRIPTION_MAX = 200
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 归一化为 kebab-case 技能名。
|
|
30
|
+
* @param raw - 原始名称。
|
|
31
|
+
* @param fallback - 归一化结果为空时使用。
|
|
32
|
+
* @returns kebab-case 名称。
|
|
33
|
+
*/
|
|
34
|
+
export function kebabName(raw, fallback = 'skill') {
|
|
35
|
+
const slug = String(raw ?? '')
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
38
|
+
.replace(/^-+|-+$/g, '')
|
|
39
|
+
.replace(/-{2,}/g, '-')
|
|
40
|
+
return SKILL_NAME_RE.test(slug) ? slug : fallback
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 判定一个技能文件是否与 DSH 契约兼容(可直拷)。
|
|
45
|
+
* @param content - 文件原文。
|
|
46
|
+
* @returns `{ compatible, name, description }`;解析失败时 compatible=false。
|
|
47
|
+
*/
|
|
48
|
+
export function classifySkill(content) {
|
|
49
|
+
let meta = {}
|
|
50
|
+
let body = String(content ?? '')
|
|
51
|
+
try {
|
|
52
|
+
const parsed = parseFrontmatter(body)
|
|
53
|
+
if (parsed) {
|
|
54
|
+
meta = parsed.meta ?? {}
|
|
55
|
+
body = parsed.body ?? ''
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// 畸形 frontmatter:按不兼容处理(走转换路径补合成 frontmatter)。
|
|
59
|
+
return { compatible: false, name: '', description: '', body }
|
|
60
|
+
}
|
|
61
|
+
const name = String(meta.name ?? '').trim()
|
|
62
|
+
const description = String(meta.description ?? '').trim()
|
|
63
|
+
return {
|
|
64
|
+
compatible: name.length > 0 && description.length > 0,
|
|
65
|
+
name,
|
|
66
|
+
description,
|
|
67
|
+
body,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 从正文猜测描述:优先首个 markdown 标题,其次首个非空行。
|
|
73
|
+
* @param body - 正文(不含 frontmatter)。
|
|
74
|
+
* @returns 截断后的描述。
|
|
75
|
+
*/
|
|
76
|
+
export function guessDescription(body) {
|
|
77
|
+
const lines = String(body ?? '').split(/\r?\n/)
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const t = line.trim()
|
|
80
|
+
if (!t) continue
|
|
81
|
+
const heading = t.match(/^#{1,6}\s+(.+)$/)
|
|
82
|
+
const text = heading ? heading[1].trim() : t
|
|
83
|
+
if (text.length === 0) continue
|
|
84
|
+
return text.length <= DESCRIPTION_MAX ? text : text.slice(0, DESCRIPTION_MAX - 3).trimEnd() + '...'
|
|
85
|
+
}
|
|
86
|
+
return ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 渲染一个 DSH 技能文件(SKILL.md 全文)。
|
|
91
|
+
* @param content - 源文件原文。
|
|
92
|
+
* @param fallbackName - frontmatter 缺 name 时的兜底名(目录名/文件名 stem)。
|
|
93
|
+
* @returns `{ content, name, description, converted }`;converted=true 表示补过 frontmatter。
|
|
94
|
+
*/
|
|
95
|
+
export function renderSkill(content, fallbackName = 'skill') {
|
|
96
|
+
const raw = String(content ?? '')
|
|
97
|
+
const { compatible, name, description, body } = classifySkill(raw)
|
|
98
|
+
if (compatible) {
|
|
99
|
+
// 兼容直拷:内容原样保留(frontmatter + 正文)。
|
|
100
|
+
return { content: raw, name: kebabName(name), description, converted: false }
|
|
101
|
+
}
|
|
102
|
+
const finalName = kebabName(name || fallbackName)
|
|
103
|
+
const finalDescription = description || guessDescription(body) || `Migrated skill ${finalName}`
|
|
104
|
+
const rendered = `---\nname: ${finalName}\ndescription: ${finalDescription}\n---\n\n${body.trim()}\n`
|
|
105
|
+
return { content: rendered, name: finalName, description: finalDescription, converted: true }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 目标技能目录路径:`$DSH_HOME/skills/<kebab-name>/SKILL.md`。
|
|
110
|
+
* @param skillsDir - DSH 技能根目录。
|
|
111
|
+
* @param name - kebab 技能名。
|
|
112
|
+
* @returns SKILL.md 绝对路径。
|
|
113
|
+
*/
|
|
114
|
+
export function skillTargetPath(skillsDir, name) {
|
|
115
|
+
return path.join(skillsDir, name, 'SKILL.md')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 技能发现守卫:跳过非技能文档与隐藏目录(.system/.hub/.git 等)。
|
|
120
|
+
* @param entryName - 目录条目名。
|
|
121
|
+
* @returns 是否应跳过。
|
|
122
|
+
*/
|
|
123
|
+
export function skipSkillEntry(entryName) {
|
|
124
|
+
const s = String(entryName ?? '')
|
|
125
|
+
if (SKIP_DIR_RE.test(s)) return true
|
|
126
|
+
if (SKIP_FILE_RE.test(s)) return true
|
|
127
|
+
return false
|
|
128
|
+
}
|