create-dwy 0.22.0 → 0.23.0
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 +12 -0
- package/package.json +1 -1
- package/src/cli-help.js +20 -11
- package/src/skill-store.js +15 -0
- package/src/skills-install.js +47 -11
- package/src/sync-all.js +167 -658
- package/src/sync-codex.js +38 -25
- package/src/sync-global-skills.js +4 -5
- package/src/sync-opencode.js +2 -2
- package/src/sync-plan.js +278 -0
- package/src/sync.js +25 -19
- package/src/tab-multiselect.js +7 -5
package/src/sync-all.js
CHANGED
|
@@ -1,94 +1,43 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
|
-
import path from 'path'
|
|
2
|
+
import path from 'node:path'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import { confirm, isCancel } from '@clack/prompts'
|
|
5
5
|
import { SEARCH_PLACEHOLDER, searchableMultiselect, searchableSelect } from './searchable-select.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
copySkillsToProjectPlatforms,
|
|
9
|
-
normalizeSkillScope,
|
|
10
|
-
resolveProjectSkillDir,
|
|
11
|
-
resolveGlobalSkillDir,
|
|
12
|
-
deduplicateProjectSkills,
|
|
13
|
-
findGlobalSkill,
|
|
14
|
-
scanGlobalSkills,
|
|
15
|
-
} from './sync-global-skills.js'
|
|
16
|
-
import { prepareSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
6
|
+
import { normalizeSkillScope, resolveProjectSkillDir, resolveGlobalSkillDir, copySkillsToProjectPlatforms, deduplicateProjectSkills } from './sync-global-skills.js'
|
|
7
|
+
import { ensureSharedSkills } from './skill-store.js'
|
|
17
8
|
import { chalk } from './utils.js'
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
resolveSourceDir,
|
|
23
|
-
scanExisting,
|
|
24
|
-
scanHooks,
|
|
25
|
-
scanRules,
|
|
26
|
-
scanSkills,
|
|
27
|
-
syncClaude,
|
|
28
|
-
} from './sync.js'
|
|
29
|
-
import {
|
|
30
|
-
parseManagedRuleNames,
|
|
31
|
-
removeLegacyBaseline,
|
|
32
|
-
scanExistingCodexHooks,
|
|
33
|
-
scanExistingCodexSkills,
|
|
34
|
-
syncCodex,
|
|
35
|
-
syncGlobalBaselineDoc,
|
|
36
|
-
syncProjectAgentsMd,
|
|
37
|
-
} from './sync-codex.js'
|
|
38
|
-
import { scanExistingCursorRules, syncCursor } from './sync-cursor.js'
|
|
39
|
-
import { syncOpenCode } from './sync-opencode.js'
|
|
40
|
-
import { ensureSkillsInstalled, installSkills } from './skills-install.js'
|
|
9
|
+
import { interactiveSelect, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
|
|
10
|
+
import { parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
|
|
11
|
+
import { scanExistingCursorRules } from './sync-cursor.js'
|
|
12
|
+
import { installSkills } from './skills-install.js'
|
|
41
13
|
import { selfUpgrade } from './self-upgrade.js'
|
|
14
|
+
import { buildSyncPlan, contentOptions, projectSkillChoices, PLATFORM_LABELS, PLATFORM_SYNC_TYPES } from './sync-plan.js'
|
|
42
15
|
|
|
43
16
|
const PLATFORM_OPTIONS = [
|
|
44
|
-
{ value: 'claude', label: 'Claude Code', description: '支持
|
|
45
|
-
{ value: 'codex', label: 'Codex', description: '支持
|
|
46
|
-
{ value: '
|
|
47
|
-
{ value: '
|
|
17
|
+
{ value: 'claude', label: 'Claude Code', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
|
|
18
|
+
{ value: 'codex', label: 'Codex', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
|
|
19
|
+
{ value: 'opencode', label: 'OpenCode', description: '支持 Skills、项目 Rules 和全局通用规范;不支持 Hooks' },
|
|
20
|
+
{ value: 'cursor', label: 'Cursor', description: '只支持项目 Rules' },
|
|
48
21
|
]
|
|
49
|
-
|
|
50
22
|
const SYNC_STATE_PATH = ['.dwy', 'sync-state.json']
|
|
51
|
-
|
|
52
|
-
claude: ['skills', 'rules', 'hooks'],
|
|
53
|
-
codex: ['skills', 'rules', 'hooks'],
|
|
54
|
-
cursor: ['rules'],
|
|
55
|
-
opencode: ['skills', 'rules'],
|
|
56
|
-
}
|
|
57
|
-
const PLATFORM_LABELS = {
|
|
58
|
-
claude: 'Claude Code',
|
|
59
|
-
codex: 'Codex',
|
|
60
|
-
cursor: 'Cursor',
|
|
61
|
-
opencode: 'OpenCode',
|
|
62
|
-
}
|
|
63
|
-
/** 完整同步:逐项选择 skills / rules,hooks 自动启用。 */
|
|
23
|
+
/** 按显式选中的内容同步;保留已有脚本的模式标识。 */
|
|
64
24
|
export const SYNC_MODE_ALL = 'all'
|
|
65
|
-
/**
|
|
25
|
+
/** 兼容已有脚本的仅 Skills 模式。 */
|
|
66
26
|
export const SYNC_MODE_SKILLS = 'skills'
|
|
67
27
|
export const DEFAULT_SYNC_MODE = SYNC_MODE_ALL
|
|
68
|
-
|
|
69
|
-
/** 同步项目配置(skills / rules / hooks) */
|
|
28
|
+
/** 交互式配置同步。 */
|
|
70
29
|
export const ACTION_SYNC = 'sync'
|
|
71
|
-
/**
|
|
30
|
+
/** 安装产品流程依赖的外部 Skills;仅供已有脚本调用。 */
|
|
72
31
|
export const ACTION_INSTALL_SKILLS = 'install-skills'
|
|
73
|
-
/**
|
|
32
|
+
/** 升级全局 CLI。 */
|
|
74
33
|
export const ACTION_UPGRADE = 'upgrade'
|
|
75
34
|
|
|
76
|
-
/**
|
|
77
|
-
* 规范化同步范围。未知值回退完整同步,避免误进仅 Skills 删不该删的东西。
|
|
78
|
-
*
|
|
79
|
-
* @param {unknown} raw
|
|
80
|
-
* @returns {'all' | 'skills'}
|
|
81
|
-
*/
|
|
35
|
+
/** 兼容已有脚本的模式值,不从模式推导交互中的隐式写入。 */
|
|
82
36
|
export function normalizeSyncMode(raw) {
|
|
83
37
|
return raw === SYNC_MODE_SKILLS ? SYNC_MODE_SKILLS : DEFAULT_SYNC_MODE
|
|
84
38
|
}
|
|
85
39
|
|
|
86
|
-
/**
|
|
87
|
-
* 规范化入口动作。未知值回退同步,避免误进强制重装。
|
|
88
|
-
*
|
|
89
|
-
* @param {unknown} raw
|
|
90
|
-
* @returns {'sync' | 'install-skills' | 'upgrade'}
|
|
91
|
-
*/
|
|
40
|
+
/** 兼容已有脚本的入口动作;未知动作进入正常交互。 */
|
|
92
41
|
export function normalizeAction(raw) {
|
|
93
42
|
if (raw === ACTION_INSTALL_SKILLS) return ACTION_INSTALL_SKILLS
|
|
94
43
|
if (raw === ACTION_UPGRADE) return ACTION_UPGRADE
|
|
@@ -99,334 +48,107 @@ function unionSets(...sets) {
|
|
|
99
48
|
return new Set(sets.flatMap(set => [...set]))
|
|
100
49
|
}
|
|
101
50
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
skills: new Set(),
|
|
109
|
-
rules: new Set(),
|
|
110
|
-
commands: new Set(),
|
|
111
|
-
hooks: new Set(),
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
for (const platformState of Object.values(syncState?.platforms || {})) {
|
|
115
|
-
for (const type of ['skills', 'rules', 'commands', 'hooks']) {
|
|
116
|
-
for (const name of platformState?.[type] || []) {
|
|
117
|
-
stateDefaults[type].add(name)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return hasSetItems(stateDefaults) ? stateDefaults : existing
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function scanCombinedExisting(projectDir, global = false) {
|
|
126
|
-
const claudeDir = path.join(projectDir, '.claude')
|
|
127
|
-
const agentsMdPath = path.join(projectDir, 'AGENTS.md')
|
|
128
|
-
const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
skills: unionSets(
|
|
132
|
-
await scanExisting(claudeDir, 'skills'),
|
|
133
|
-
await scanExistingCodexSkills(projectDir),
|
|
134
|
-
await scanExisting(path.join(projectDir, ...(global ? ['.config', 'opencode'] : ['.opencode'])), 'skills'),
|
|
135
|
-
),
|
|
136
|
-
rules: unionSets(
|
|
137
|
-
await scanExisting(claudeDir, 'rules'),
|
|
138
|
-
parseManagedRuleNames(agentsMdContent),
|
|
139
|
-
),
|
|
140
|
-
hooks: unionSets(
|
|
141
|
-
await scanExisting(claudeDir, 'hooks'),
|
|
142
|
-
await scanExistingCodexHooks(projectDir),
|
|
143
|
-
),
|
|
51
|
+
/** 从点名的平台记录恢复选择;未传平台时保留历史调用约定。 */
|
|
52
|
+
export function buildSelectionDefaultsFromSyncState(existing, syncState, platforms = Object.keys(syncState?.platforms || {})) {
|
|
53
|
+
const result = { skills: new Set(), rules: new Set(), commands: new Set(), hooks: new Set() }
|
|
54
|
+
for (const platform of platforms) {
|
|
55
|
+
const state = syncState?.platforms?.[platform] || {}
|
|
56
|
+
for (const type of Object.keys(result)) for (const name of state[type] || []) result[type].add(name)
|
|
144
57
|
}
|
|
58
|
+
return Object.values(result).some(names => names.size) ? result : existing
|
|
145
59
|
}
|
|
146
60
|
|
|
147
|
-
|
|
61
|
+
/** 从当前范围的实际入口推导平台默认值;不读取另一个范围的配置。 */
|
|
62
|
+
export async function buildPlatformDefaultsFromLocalState(projectDir, scope = 'project', codexHome = path.join(projectDir, '.codex')) {
|
|
148
63
|
const defaults = []
|
|
149
|
-
|
|
150
|
-
if (await fs.pathExists(path.join(projectDir, '.
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
if (
|
|
154
|
-
await fs.pathExists(path.join(projectDir, '.codex'))
|
|
64
|
+
if (await fs.pathExists(path.join(projectDir, '.claude'))) defaults.push('claude')
|
|
65
|
+
if (await fs.pathExists(path.join(projectDir, '.codex'))
|
|
66
|
+
|| (scope === 'global' && await fs.pathExists(codexHome))
|
|
155
67
|
|| await fs.pathExists(path.join(projectDir, '.agents'))
|
|
156
|
-
|| await fs.pathExists(path.join(projectDir, 'AGENTS.md'))
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (await fs.pathExists(path.join(projectDir, '.cursor'))) {
|
|
161
|
-
defaults.push('cursor')
|
|
162
|
-
}
|
|
163
|
-
if (await fs.pathExists(path.join(projectDir, '.opencode'))) {
|
|
164
|
-
defaults.push('opencode')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return defaults.length > 0 ? defaults : ['claude', 'codex']
|
|
68
|
+
|| (scope === 'project' && await fs.pathExists(path.join(projectDir, 'AGENTS.md')))) defaults.push('codex')
|
|
69
|
+
if (scope === 'project' && await fs.pathExists(path.join(projectDir, '.cursor'))) defaults.push('cursor')
|
|
70
|
+
if (await fs.pathExists(path.join(projectDir, ...(scope === 'global' ? ['.config', 'opencode'] : ['.opencode'])))) defaults.push('opencode')
|
|
71
|
+
return defaults.length ? defaults : ['claude', 'codex']
|
|
168
72
|
}
|
|
169
73
|
|
|
170
|
-
|
|
171
|
-
* 仅 Skills 模式:只写项目/全局 skill,并清理未选中的模板 skill。
|
|
172
|
-
* 禁止调用完整平台 sync,否则空 rules/hooks 会删掉项目已有项。
|
|
173
|
-
*
|
|
174
|
-
* @param {{ projectDir: string, selectedPlatforms: string[], skills: Array<{ name: string, sourcePath: string }>, templateSkillNames: Set<string>, staleSkillNames: Set<string>, skillScope: { destinations: string[] } }} opts
|
|
175
|
-
*/
|
|
176
|
-
async function syncSkillsOnly({
|
|
177
|
-
projectDir,
|
|
178
|
-
selectedPlatforms,
|
|
179
|
-
skills,
|
|
180
|
-
templateSkillNames,
|
|
181
|
-
staleSkillNames,
|
|
182
|
-
skillScope,
|
|
183
|
-
homeDir,
|
|
184
|
-
global,
|
|
185
|
-
duplicateNames,
|
|
186
|
-
}) {
|
|
187
|
-
const writeProject = skillScope.destinations.includes('project')
|
|
188
|
-
if (!writeProject) return
|
|
189
|
-
|
|
190
|
-
const skillPlatforms = selectedPlatforms.filter(platform => resolveProjectSkillDir(projectDir, platform))
|
|
191
|
-
if (skillPlatforms.length === 0) {
|
|
192
|
-
console.log(chalk.yellow('已选平台不支持项目内 skills,跳过项目写入。'))
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
console.log(chalk.blue('\nSyncing skills only (rules / commands / hooks untouched)...\n'))
|
|
197
|
-
for (const platform of skillPlatforms) {
|
|
198
|
-
const platformSkills = skills.filter(skill => !duplicateNames[platform]?.has(skill.name))
|
|
199
|
-
if (global) {
|
|
200
|
-
await copySkillsToGlobalDirs({ skills: platformSkills, destIds: [platform === 'codex' ? 'agents' : platform], homeDir })
|
|
201
|
-
} else {
|
|
202
|
-
await copySkillsToProjectPlatforms({ projectDir, platforms: [platform], skills: platformSkills, homeDir })
|
|
203
|
-
}
|
|
204
|
-
const selectedNames = new Set(platformSkills.map(item => item.name))
|
|
205
|
-
const toRemove = new Set([...templateSkillNames, ...staleSkillNames].filter(name => !selectedNames.has(name)))
|
|
206
|
-
const destRoot = global
|
|
207
|
-
? resolveGlobalSkillDir(platform === 'codex' ? 'agents' : platform, homeDir)
|
|
208
|
-
: resolveProjectSkillDir(projectDir, platform)
|
|
209
|
-
const existingNames = await scanExisting(path.dirname(destRoot), 'skills')
|
|
210
|
-
await removeManagedItems(
|
|
211
|
-
destRoot,
|
|
212
|
-
existingNames,
|
|
213
|
-
toRemove,
|
|
214
|
-
path.relative(projectDir, destRoot),
|
|
215
|
-
path.join(global ? homeDir : projectDir, '.dwy', 'skill-backups'),
|
|
216
|
-
)
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async function selectPlatforms(projectDir) {
|
|
221
|
-
const initialValues = await buildPlatformDefaultsFromLocalState(projectDir)
|
|
222
|
-
|
|
223
|
-
// 可搜索多选 + 底部说明区
|
|
74
|
+
async function selectPlatforms(root, scope, codexHome) {
|
|
224
75
|
const result = await searchableMultiselect({
|
|
225
76
|
message: '选择要同步的平台',
|
|
226
|
-
options: PLATFORM_OPTIONS
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
maxItems: 6,
|
|
230
|
-
placeholder: SEARCH_PLACEHOLDER,
|
|
77
|
+
options: PLATFORM_OPTIONS.filter(option => scope === 'project' || option.value !== 'cursor')
|
|
78
|
+
.map(option => ({ ...option, description: contentOptions(scope, [option.value]).map(item => item.label).join('、') })),
|
|
79
|
+
initialValues: await buildPlatformDefaultsFromLocalState(root, scope, codexHome),
|
|
80
|
+
required: true, maxItems: 6, placeholder: SEARCH_PLACEHOLDER,
|
|
231
81
|
})
|
|
232
|
-
|
|
233
|
-
return result
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function buildCursorRuleName(ruleName) {
|
|
237
|
-
return `dwy-${ruleName.replace(/\.md$/i, '')}.mdc`
|
|
82
|
+
return isCancel(result) ? null : result
|
|
238
83
|
}
|
|
239
84
|
|
|
240
85
|
function createEmptyPlatformState() {
|
|
241
|
-
return {
|
|
242
|
-
skills: [],
|
|
243
|
-
rules: [],
|
|
244
|
-
commands: [],
|
|
245
|
-
hooks: [],
|
|
246
|
-
}
|
|
86
|
+
return { skills: [], rules: [], commands: [], hooks: [] }
|
|
247
87
|
}
|
|
248
88
|
|
|
249
|
-
async function loadSyncState(
|
|
250
|
-
const
|
|
251
|
-
if (!await fs.pathExists(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
platforms: {},
|
|
255
|
-
skillScope: normalizeSkillScope(undefined),
|
|
256
|
-
syncMode: DEFAULT_SYNC_MODE,
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const state = await fs.readJson(statePath)
|
|
261
|
-
return {
|
|
262
|
-
version: 1,
|
|
263
|
-
platforms: state.platforms || {},
|
|
264
|
-
skillScope: normalizeSkillScope(state.skillScope),
|
|
265
|
-
syncMode: normalizeSyncMode(state.syncMode),
|
|
266
|
-
}
|
|
89
|
+
async function loadSyncState(root) {
|
|
90
|
+
const file = path.join(root, ...SYNC_STATE_PATH)
|
|
91
|
+
if (!await fs.pathExists(file)) return { version: 1, platforms: {}, skillScope: normalizeSkillScope(undefined), syncMode: DEFAULT_SYNC_MODE }
|
|
92
|
+
const state = await fs.readJson(file)
|
|
93
|
+
return { version: 1, platforms: state.platforms || {}, skillScope: normalizeSkillScope(state.skillScope), syncMode: normalizeSyncMode(state.syncMode) }
|
|
267
94
|
}
|
|
268
95
|
|
|
269
|
-
async function saveSyncState(
|
|
270
|
-
const
|
|
271
|
-
await fs.ensureDir(path.dirname(
|
|
272
|
-
await fs.writeJson(
|
|
96
|
+
async function saveSyncState(root, state) {
|
|
97
|
+
const file = path.join(root, ...SYNC_STATE_PATH)
|
|
98
|
+
await fs.ensureDir(path.dirname(file))
|
|
99
|
+
await fs.writeJson(file, state, { spaces: 2 })
|
|
273
100
|
}
|
|
274
101
|
|
|
275
|
-
function
|
|
276
|
-
return {
|
|
277
|
-
skills: new Set(scans.skills.map(item => item.name)),
|
|
278
|
-
rules: new Set(scans.rules.map(item => item.name)),
|
|
279
|
-
commands: new Set(scans.commands.map(item => item.name)),
|
|
280
|
-
hooks: new Set(scans.hooks.map(item => item.name)),
|
|
281
|
-
}
|
|
102
|
+
function buildCursorRuleName(name) {
|
|
103
|
+
return `dwy-${name.replace(/\.md$/i, '')}.mdc`
|
|
282
104
|
}
|
|
283
105
|
|
|
284
|
-
async function
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
rules: unionSets(await scanExisting(claudeDir, 'rules'), parseManagedRuleNames(agentsMdContent)),
|
|
294
|
-
hooks: await scanExisting(claudeDir, 'hooks'),
|
|
295
|
-
},
|
|
296
|
-
codex: {
|
|
297
|
-
skills: await scanExistingCodexSkills(projectDir),
|
|
298
|
-
rules: parseManagedRuleNames(agentsMdContent),
|
|
299
|
-
hooks: await scanExistingCodexHooks(projectDir),
|
|
300
|
-
},
|
|
301
|
-
cursor: {
|
|
302
|
-
rules: await scanExistingCursorRules(projectDir),
|
|
303
|
-
},
|
|
304
|
-
opencode: {
|
|
305
|
-
skills: await scanExisting(openCodeDir, 'skills'),
|
|
306
|
-
rules: global ? parseManagedRuleNames(await fs.pathExists(path.join(openCodeDir, 'AGENTS.md')) ? await fs.readFile(path.join(openCodeDir, 'AGENTS.md'), 'utf-8') : '') : parseManagedRuleNames(agentsMdContent),
|
|
307
|
-
},
|
|
106
|
+
async function managedNames(root, global, codexHome) {
|
|
107
|
+
const projectFile = path.join(root, 'AGENTS.md')
|
|
108
|
+
const projectRules = !global && await fs.pathExists(projectFile)
|
|
109
|
+
? parseManagedRuleNames(await fs.readFile(projectFile, 'utf8')) : new Set()
|
|
110
|
+
const claude = path.join(root, '.claude')
|
|
111
|
+
const opencode = path.join(root, ...(global ? ['.config', 'opencode'] : ['.opencode']))
|
|
112
|
+
const codexSkills = await scanExistingCodexSkills(root)
|
|
113
|
+
if (global && await fs.pathExists(path.join(codexHome, 'skills'))) {
|
|
114
|
+
for (const name of await fs.readdir(path.join(codexHome, 'skills'))) codexSkills.add(name)
|
|
308
115
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
normalized[platform] = {}
|
|
315
|
-
for (const [type, names] of Object.entries(types || {})) {
|
|
316
|
-
normalized[platform][type] = new Set(Array.isArray(names) ? names : [...names])
|
|
317
|
-
}
|
|
116
|
+
return {
|
|
117
|
+
claude: { skills: await scanExisting(claude, 'skills'), rules: unionSets(projectRules, await scanExisting(claude, 'rules')), hooks: await scanExisting(claude, 'hooks') },
|
|
118
|
+
codex: { skills: codexSkills, rules: projectRules, hooks: await scanExistingCodexHooks(root, global ? codexHome : path.join(root, '.codex')) },
|
|
119
|
+
opencode: { skills: await scanExisting(opencode, 'skills'), rules: projectRules, hooks: new Set() },
|
|
120
|
+
cursor: { rules: await scanExistingCursorRules(root), skills: new Set(), hooks: new Set() },
|
|
318
121
|
}
|
|
319
|
-
return normalized
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function createEmptyRemovalState() {
|
|
323
|
-
return Object.fromEntries(
|
|
324
|
-
Object.keys(PLATFORM_SYNC_TYPES).map(platform => [platform, {}]),
|
|
325
|
-
)
|
|
326
122
|
}
|
|
327
123
|
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
for (const type of PLATFORM_SYNC_TYPES[platform] || []) {
|
|
338
|
-
const previousNames = previous[type] || []
|
|
339
|
-
const currentNames = currentTemplateNames[type] || new Set()
|
|
340
|
-
const managedNames = platformManaged[type] || new Set()
|
|
341
|
-
const staleNames = []
|
|
342
|
-
|
|
343
|
-
for (const name of previousNames) {
|
|
344
|
-
if (currentNames.has(name)) continue
|
|
345
|
-
if (type === 'rules' && platform === 'cursor') {
|
|
346
|
-
if (!managedNames.has(buildCursorRuleName(name))) continue
|
|
347
|
-
} else if (!managedNames.has(name)) {
|
|
348
|
-
continue
|
|
349
|
-
}
|
|
350
|
-
staleNames.push(name)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (staleNames.length > 0) staleEntries[platform][type] = staleNames.sort((a, b) => a.localeCompare(b, 'zh'))
|
|
124
|
+
function staleEntries(scans, state, existing, platforms) {
|
|
125
|
+
const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, new Set(scans[type].map(item => item.name))]))
|
|
126
|
+
const result = {}
|
|
127
|
+
for (const platform of platforms) {
|
|
128
|
+
result[platform] = {}
|
|
129
|
+
for (const type of PLATFORM_SYNC_TYPES[platform]) {
|
|
130
|
+
result[platform][type] = (state.platforms[platform]?.[type] || []).filter(name =>
|
|
131
|
+
!current[type].has(name) && existing[platform][type].has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
|
|
354
132
|
}
|
|
355
133
|
}
|
|
356
|
-
|
|
357
|
-
return staleEntries
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* 仅 Skills 模式:陈旧项提示只保留 skills,避免问 rules/commands/hooks。
|
|
362
|
-
*
|
|
363
|
-
* @param {Record<string, Record<string, string[]>>} staleEntries
|
|
364
|
-
*/
|
|
365
|
-
function retainSkillStaleEntries(staleEntries) {
|
|
366
|
-
const next = createEmptyRemovalState()
|
|
367
|
-
for (const [platform, types] of Object.entries(staleEntries)) {
|
|
368
|
-
if (types.skills?.length) next[platform] = { skills: types.skills }
|
|
369
|
-
}
|
|
370
|
-
return next
|
|
134
|
+
return result
|
|
371
135
|
}
|
|
372
136
|
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return new Set(staleNames.filter(name => !approvedNames.has(name)))
|
|
137
|
+
function normalizeRemovals(raw) {
|
|
138
|
+
return Object.fromEntries(Object.entries(raw).map(([platform, types]) => [platform,
|
|
139
|
+
Object.fromEntries(Object.entries(types).map(([type, names]) => [type, new Set(names)]))]))
|
|
377
140
|
}
|
|
378
141
|
|
|
379
|
-
|
|
380
|
-
const state = createEmptyPlatformState()
|
|
381
|
-
|
|
382
|
-
for (const type of PLATFORM_SYNC_TYPES[platform] || []) {
|
|
383
|
-
const selectedNames = new Set((selected[type] || []).map(item => item.name))
|
|
384
|
-
for (const name of staleEntries[platform]?.[type] || []) {
|
|
385
|
-
const approvedNames = approvedRemovals[platform]?.[type] || new Set()
|
|
386
|
-
if (!approvedNames.has(name)) selectedNames.add(name)
|
|
387
|
-
}
|
|
388
|
-
state[type] = [...selectedNames].sort((a, b) => a.localeCompare(b, 'zh'))
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
return state
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix, backupRoot) {
|
|
395
|
-
let removedCount = 0
|
|
396
|
-
|
|
397
|
-
for (const name of existingNames) {
|
|
398
|
-
if (!managedNames.has(name)) continue
|
|
399
|
-
await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot })
|
|
400
|
-
logAction(`${labelPrefix}/${name}`, 'red', '×')
|
|
401
|
-
removedCount++
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
return removedCount
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* `dwy` / `dwy sync` 直接进入内容选择,再选位置和平台。
|
|
409
|
-
* action 仅供已有脚本调用;主交互不再显示动作菜单。
|
|
410
|
-
*
|
|
411
|
-
* @param {object} [opts]
|
|
412
|
-
*/
|
|
142
|
+
/** `dwy` 和 `dwy sync` 共用范围 → 平台 → 内容 → 逐项选择 → 清单确认。 */
|
|
413
143
|
export async function runDwy(opts = {}) {
|
|
414
144
|
console.log(chalk.bold.cyan('\ndwy') + chalk.gray(' · AI 开发工具\n'))
|
|
415
145
|
const action = normalizeAction(opts.action)
|
|
416
|
-
if (action === ACTION_INSTALL_SKILLS) {
|
|
417
|
-
await installSkills()
|
|
418
|
-
return
|
|
419
|
-
}
|
|
146
|
+
if (action === ACTION_INSTALL_SKILLS) { await installSkills(); return }
|
|
420
147
|
if (action === ACTION_UPGRADE) {
|
|
421
|
-
try {
|
|
422
|
-
await selfUpgrade()
|
|
423
|
-
} catch (error) {
|
|
424
|
-
console.error(error.message)
|
|
425
|
-
process.exitCode = 1
|
|
426
|
-
}
|
|
148
|
+
try { await selfUpgrade() } catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
427
149
|
return
|
|
428
150
|
}
|
|
429
|
-
|
|
151
|
+
return syncAll(opts)
|
|
430
152
|
}
|
|
431
153
|
|
|
432
154
|
/**
|
|
@@ -439,13 +161,16 @@ export async function migrateSkills({
|
|
|
439
161
|
homeDir = os.homedir(),
|
|
440
162
|
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
|
|
441
163
|
} = {}) {
|
|
164
|
+
const realProject = await fs.realpath(projectDir)
|
|
165
|
+
if (await fs.pathExists(homeDir) && realProject === await fs.realpath(homeDir)) {
|
|
166
|
+
throw new Error('当前目录是用户主目录,请使用全局同步或进入项目目录迁移')
|
|
167
|
+
}
|
|
442
168
|
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
443
169
|
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
444
170
|
const state = await loadSyncState(projectDir)
|
|
445
171
|
const existingByPlatform = {}
|
|
446
172
|
const neededNames = new Set()
|
|
447
173
|
const retained = []
|
|
448
|
-
const realProject = await fs.realpath(projectDir)
|
|
449
174
|
for (const platform of ['claude', 'codex', 'opencode']) {
|
|
450
175
|
const root = resolveProjectSkillDir(projectDir, platform)
|
|
451
176
|
if (!await fs.pathExists(root)) continue
|
|
@@ -466,7 +191,7 @@ export async function migrateSkills({
|
|
|
466
191
|
}
|
|
467
192
|
}
|
|
468
193
|
}
|
|
469
|
-
const shared = await
|
|
194
|
+
const shared = await ensureSharedSkills({ skills: [...neededNames].map(name => available.get(name)), homeDir })
|
|
470
195
|
let linked = 0
|
|
471
196
|
let globalCount = 0
|
|
472
197
|
for (const [platform, names] of Object.entries(existingByPlatform)) {
|
|
@@ -485,310 +210,94 @@ export async function migrateSkills({
|
|
|
485
210
|
return { linked, global: globalCount, retained }
|
|
486
211
|
}
|
|
487
212
|
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 只执行点名范围和内容;测试/脚本可注入选择,确认前只读。
|
|
216
|
+
* contentTypes 未传时,已有脚本按 selected 中明确提供的字段处理;主交互默认只选 Skills。
|
|
217
|
+
*/
|
|
488
218
|
export async function syncAll({
|
|
489
|
-
sourceDir: sourceDirOverride,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
staleRemovals: staleRemovalsOverride,
|
|
494
|
-
// 兼容已有脚本;主交互的位置只控制 Skills / Hooks。
|
|
495
|
-
skillScope: skillScopeOverride,
|
|
496
|
-
// all = 完整同步;skills = 只写 skill,测试/脚本可注入
|
|
497
|
-
syncMode: syncModeOverride,
|
|
498
|
-
// 全局配置根目录;测试必须注入临时用户目录。
|
|
499
|
-
homeDir = os.homedir(),
|
|
500
|
-
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
|
|
501
|
-
scope: scopeOverride,
|
|
502
|
-
confirmed,
|
|
219
|
+
sourceDir: sourceDirOverride, projectDir: projectDirOverride, selected: selectedOverride,
|
|
220
|
+
selectedPlatforms: platformsOverride, contentTypes: typesOverride, staleRemovals: removalsOverride,
|
|
221
|
+
skillScope: skillScopeOverride, syncMode: modeOverride, homeDir = os.homedir(),
|
|
222
|
+
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'), scope: scopeOverride, confirmed,
|
|
503
223
|
} = {}) {
|
|
504
224
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
505
|
-
if (!await fs.pathExists(sourceDir))
|
|
506
|
-
throw new Error('ai-tools templates not found in repo')
|
|
507
|
-
}
|
|
508
|
-
|
|
225
|
+
if (!await fs.pathExists(sourceDir)) throw new Error('ai-tools templates not found in repo')
|
|
509
226
|
const projectDir = projectDirOverride || process.cwd()
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
skills: await scanSkills(sourceDir),
|
|
513
|
-
rules: await scanRules(sourceDir),
|
|
514
|
-
commands: [],
|
|
515
|
-
hooks: await scanHooks(sourceDir),
|
|
516
|
-
}
|
|
517
|
-
const existing = await scanCombinedExisting(projectDir)
|
|
518
|
-
const syncState = await loadSyncState(projectDir)
|
|
519
|
-
const globalState = await loadSyncState(homeDir)
|
|
520
|
-
const projectDefaults = buildSelectionDefaultsFromSyncState(existing, syncState)
|
|
521
|
-
const globalExisting = await scanCombinedExisting(homeDir, true)
|
|
522
|
-
globalExisting.skills = await scanGlobalSkills({ homeDir, codexHome })
|
|
523
|
-
const globalDefaults = buildSelectionDefaultsFromSyncState(globalExisting, globalState)
|
|
524
|
-
globalDefaults.skills = unionSets(globalDefaults.skills, globalExisting.skills)
|
|
525
|
-
const selectionDefaults = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, unionSets(projectDefaults[type], globalDefaults[type])]))
|
|
526
|
-
selectionDefaults.rules = projectDefaults.rules
|
|
527
|
-
const syncMode = normalizeSyncMode(syncModeOverride)
|
|
528
|
-
const selected = selectedOverride || await interactiveSelect(scans, selectionDefaults, {
|
|
529
|
-
skillsOnly: syncMode === SYNC_MODE_SKILLS,
|
|
530
|
-
})
|
|
531
|
-
if (selected === null) {
|
|
532
|
-
console.log(chalk.yellow('\n已取消同步。'))
|
|
533
|
-
return
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (syncMode === SYNC_MODE_ALL) selected.hooks = scans.hooks
|
|
537
|
-
|
|
538
|
-
const scopeResult = scopeOverride || (selectedOverride || selectedPlatformsOverride ? 'project' : await searchableSelect({
|
|
539
|
-
message: '选择同步位置(Skills / Hooks)',
|
|
227
|
+
const scope = scopeOverride || (selectedOverride || platformsOverride ? 'project' : await searchableSelect({
|
|
228
|
+
message: '选择同步范围',
|
|
540
229
|
options: [
|
|
541
|
-
{ value: 'project', label: '当前项目', description: 'Skills
|
|
542
|
-
{ value: 'global', label: '全局', description: 'Skills
|
|
543
|
-
],
|
|
544
|
-
initialValue: 'project',
|
|
230
|
+
{ value: 'project', label: '当前项目', description: '管理项目启用的 Skills、项目 Rules 和 Hooks;全局已有 Skills 自动去重' },
|
|
231
|
+
{ value: 'global', label: '全局', description: '管理用户级 Skills、通用规范和 Hooks;项目重复 Skill 入口会明确列出清理项' },
|
|
232
|
+
], initialValue: 'project',
|
|
545
233
|
}))
|
|
546
|
-
if (isCancel(
|
|
547
|
-
if (!['project', 'global'].includes(
|
|
548
|
-
const global =
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
]
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
234
|
+
if (isCancel(scope)) { console.log(chalk.yellow('已取消同步。')); return }
|
|
235
|
+
if (!['project', 'global'].includes(scope)) throw new Error('无效同步范围')
|
|
236
|
+
const global = scope === 'global', targetRoot = global ? homeDir : projectDir
|
|
237
|
+
const platforms = platformsOverride || await selectPlatforms(targetRoot, scope, codexHome)
|
|
238
|
+
if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
|
|
239
|
+
if (platforms.length === 0 || platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))) throw new Error('所选平台不支持此同步范围')
|
|
240
|
+
const options = contentOptions(scope, platforms)
|
|
241
|
+
const allowed = new Set(options.map(option => option.value))
|
|
242
|
+
let contentTypes = typesOverride
|
|
243
|
+
if (!contentTypes && modeOverride === SYNC_MODE_SKILLS) contentTypes = ['skills']
|
|
244
|
+
if (!contentTypes && selectedOverride) contentTypes = Object.keys(selectedOverride).filter(type => allowed.has(type))
|
|
245
|
+
if (!contentTypes) {
|
|
246
|
+
const result = await searchableMultiselect({
|
|
247
|
+
message: '选择本次处理内容(未勾选的类型保持原样)', options,
|
|
248
|
+
initialValues: allowed.has('skills') ? ['skills'] : [], required: true, maxItems: 5,
|
|
249
|
+
})
|
|
250
|
+
if (isCancel(result)) { console.log(chalk.yellow('已取消同步。')); return }
|
|
251
|
+
contentTypes = result
|
|
252
|
+
}
|
|
253
|
+
if (contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
|
|
254
|
+
if (contentTypes.length === 0) { console.log('未选择处理内容。'); return }
|
|
255
|
+
const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
|
|
256
|
+
const projectState = await loadSyncState(projectDir), globalState = await loadSyncState(homeDir)
|
|
257
|
+
const targetState = global ? globalState : projectState
|
|
258
|
+
const existing = await managedNames(targetRoot, global, codexHome)
|
|
259
|
+
const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type,
|
|
260
|
+
unionSets(...platforms.map(platform => existing[platform][type]))]))
|
|
261
|
+
const defaults = buildSelectionDefaultsFromSyncState(current, targetState, platforms)
|
|
262
|
+
for (const type of ['skills', 'rules', 'hooks']) defaults[type] = unionSets(defaults[type], current[type])
|
|
263
|
+
const choices = !global && contentTypes.includes('skills')
|
|
264
|
+
? { ...scans, skills: await projectSkillChoices(scans.skills, platforms, { homeDir, codexHome }) } : scans
|
|
265
|
+
const selected = selectedOverride
|
|
266
|
+
? { skills: selectedOverride.skills || [], rules: selectedOverride.rules || [], hooks: [], commands: [] }
|
|
267
|
+
: await interactiveSelect(choices, defaults, { types: contentTypes })
|
|
268
|
+
if (selected === null) { console.log(chalk.yellow('已取消同步。')); return }
|
|
269
|
+
const stale = staleEntries(scans, targetState, existing, platforms)
|
|
270
|
+
const removals = normalizeRemovals(removalsOverride || stale)
|
|
271
|
+
const baseline = contentTypes.some(type => type === 'rules' || type === 'baseline') ? await readBaselineDoc(sourceDir) : ''
|
|
272
|
+
const plan = await buildSyncPlan({
|
|
273
|
+
scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
|
|
274
|
+
projectState, globalState, staleRemovals: removals, staleEntries: stale, baseline,
|
|
275
|
+
skillScope: normalizeSkillScope(skillScopeOverride),
|
|
276
|
+
})
|
|
277
|
+
const nextTarget = global ? plan.nextGlobal : plan.nextProject
|
|
278
|
+
nextTarget.contentTypes = contentTypes
|
|
279
|
+
nextTarget.syncMode = contentTypes.length === 1 && contentTypes[0] === 'skills' ? SYNC_MODE_SKILLS : SYNC_MODE_ALL
|
|
280
|
+
nextTarget.skillScope = normalizeSkillScope(skillScopeOverride)
|
|
281
|
+
const stateFile = path.join(targetRoot, ...SYNC_STATE_PATH)
|
|
282
|
+
plan.operations.push({ kind: 'state', label: '更新当前范围同步记录', paths: [stateFile], run: () => saveSyncState(targetRoot, nextTarget) })
|
|
283
|
+
if (global && plan.projectChanged) plan.operations.push({
|
|
284
|
+
kind: 'state', label: '更新项目去重记录', paths: [path.join(projectDir, ...SYNC_STATE_PATH)],
|
|
285
|
+
run: () => saveSyncState(projectDir, plan.nextProject),
|
|
286
|
+
})
|
|
287
|
+
console.log(`\n同步范围:${global ? '全局' : '当前项目'};平台:${platforms.map(p => PLATFORM_LABELS[p]).join('、')}`)
|
|
288
|
+
console.log(`处理内容:${contentTypes.map(type => options.find(option => option.value === type).label).join('、')}`)
|
|
289
|
+
if (contentTypes.includes('skills')) console.log(`Skills:${selected.skills.map(item => item.name).join('、') || '无项目安装选择;已有全局入口仅去重'}`)
|
|
290
|
+
if (contentTypes.includes('rules')) console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '移除当前项目托管规则'}`)
|
|
291
|
+
if (contentTypes.includes('baseline')) console.log(`全局通用规范摘要:${baseline.split('\n').filter(line => /^#/.test(line)).slice(0,3).join(';')}`)
|
|
292
|
+
for (const operation of plan.operations) {
|
|
293
|
+
console.log(operation.label)
|
|
294
|
+
for (const dest of operation.paths) console.log(` → ${dest}`)
|
|
585
295
|
}
|
|
586
296
|
if ((!selectedOverride && confirmed !== true) || confirmed === false) {
|
|
587
|
-
const currentManaged = await collectCurrentManagedNames(targetRoot, global)
|
|
588
|
-
const projectManaged = await collectCurrentManagedNames(projectDir)
|
|
589
|
-
for (const platform of selectedPlatforms) currentManaged[platform].rules = projectManaged[platform].rules
|
|
590
|
-
const templateNames = getCurrentTemplateNames(scans)
|
|
591
|
-
console.log(`\nSkills:${selected.skills.map(item => item.name).join('、') || '未选择'}`)
|
|
592
|
-
console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '未选择'}`)
|
|
593
|
-
console.log(`Skills / Hooks 位置:${global ? '全局' : '当前项目'};平台:${selectedPlatforms.map(platform => PLATFORM_LABELS[platform]).join('、')}`)
|
|
594
|
-
if (syncMode === SYNC_MODE_ALL) {
|
|
595
|
-
for (const platform of cliPlatforms) console.log(`全局基线 ${PLATFORM_LABELS[platform]} → ${baselinePaths[platform]}(替换基线并清除旧托管 Rules)`)
|
|
596
|
-
if (cliPlatforms.length) console.log(`项目 Rules → ${path.join(projectDir, 'AGENTS.md')}(保留手写内容,移除旧托管基线)`)
|
|
597
|
-
if (selectedPlatforms.includes('claude')) console.log('旧 Claude 基线入口仅清理 dwy 托管块或与当前模板完全一致的内容。')
|
|
598
|
-
for (const dest of legacyClaudeRules) console.log(`删除旧 Claude Rules → ${dest}`)
|
|
599
|
-
}
|
|
600
|
-
console.log('Hooks 按支持的平台自动启用。')
|
|
601
|
-
console.log('Skills 将迁为用户共享副本的目录链接;原真实目录先备份,同平台全局入口优先。')
|
|
602
|
-
for (const platform of selectedPlatforms) {
|
|
603
|
-
for (const type of PLATFORM_SYNC_TYPES[platform]) {
|
|
604
|
-
const existingNames = currentManaged[platform]?.[type] || new Set()
|
|
605
|
-
const previousState = type === 'rules' ? syncState : targetState
|
|
606
|
-
const previous = [...new Set([...templateNames[type], ...(previousState.platforms[platform]?.[type] || [])])]
|
|
607
|
-
.filter(name => existingNames.has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
|
|
608
|
-
const names = new Set((selected[type] || []).map(item => item.name))
|
|
609
|
-
const removed = previous.filter(name => !names.has(name))
|
|
610
|
-
const changed = [...names].filter(name => existingNames.has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
|
|
611
|
-
if (changed.length) console.log(`更新 ${PLATFORM_LABELS[platform]} ${type}:${changed.join('、')}`)
|
|
612
|
-
if (removed.length) console.log(`删除 ${PLATFORM_LABELS[platform]} ${type}:${removed.join('、')}`)
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
297
|
const answer = confirmed === false ? false : await confirm({ message: '确认同步?' })
|
|
616
298
|
if (isCancel(answer) || !answer) { console.log(chalk.yellow('已取消同步。')); return }
|
|
617
299
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const globalSkillNameSet = new Set(skillScope.globalSkills)
|
|
622
|
-
const neededNames = new Set(skillScope.destinations.some(id => id !== 'project') ? globalSkillNameSet : [])
|
|
623
|
-
if (selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))) {
|
|
624
|
-
for (const name of selectedSkillNames) neededNames.add(name)
|
|
625
|
-
}
|
|
626
|
-
for (const platform of selectedPlatforms) {
|
|
627
|
-
for (const skill of scans.skills) {
|
|
628
|
-
if (await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) neededNames.add(skill.name)
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const sharedSkills = await prepareSharedSkills({ skills: scans.skills.filter(item => neededNames.has(item.name)), homeDir })
|
|
632
|
-
const skillsToWrite = sharedSkills.filter(item => selectedSkillNames.has(item.name))
|
|
633
|
-
const globalSkills = sharedSkills.filter(item => globalSkillNameSet.has(item.name))
|
|
634
|
-
if (globalSkills.length > 0 && skillScope.destinations.some(id => id !== 'project')) {
|
|
635
|
-
await copySkillsToGlobalDirs({ skills: globalSkills, destIds: skillScope.destinations, homeDir })
|
|
636
|
-
}
|
|
637
|
-
const duplicateNames = {}
|
|
638
|
-
if (!global) {
|
|
639
|
-
for (const platform of selectedPlatforms) {
|
|
640
|
-
duplicateNames[platform] = await deduplicateProjectSkills({ projectDir, platform, skills: sharedSkills, homeDir, codexHome })
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const selectionFor = platform => ({
|
|
644
|
-
...selected,
|
|
645
|
-
skills: selected.skills.filter(skill => !duplicateNames[platform]?.has(skill.name)),
|
|
646
|
-
})
|
|
647
|
-
let syncedAnySupportedPlatform = false
|
|
648
|
-
|
|
649
|
-
if (syncMode === SYNC_MODE_SKILLS) {
|
|
650
|
-
const staleSkillNames = new Set()
|
|
651
|
-
for (const platform of selectedPlatforms) {
|
|
652
|
-
for (const name of approvedStaleRemovals[platform]?.skills || []) staleSkillNames.add(name)
|
|
653
|
-
}
|
|
654
|
-
await syncSkillsOnly({
|
|
655
|
-
projectDir,
|
|
656
|
-
selectedPlatforms,
|
|
657
|
-
skills: skillsToWrite,
|
|
658
|
-
templateSkillNames: new Set(scans.skills.map(item => item.name)),
|
|
659
|
-
staleSkillNames,
|
|
660
|
-
skillScope,
|
|
661
|
-
homeDir,
|
|
662
|
-
global,
|
|
663
|
-
duplicateNames,
|
|
664
|
-
})
|
|
665
|
-
syncedAnySupportedPlatform = selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))
|
|
666
|
-
|| skillScope.destinations.some(id => id !== 'project')
|
|
667
|
-
} else {
|
|
668
|
-
for (const platform of cliPlatforms) await syncGlobalBaselineDoc(baseline, baselinePaths[platform])
|
|
669
|
-
if (cliPlatforms.length) {
|
|
670
|
-
const preservedRuleNames = unionSets(...cliPlatforms.map(platform => getPreservedStaleNames(staleEntries, approvedStaleRemovals, platform, 'rules')))
|
|
671
|
-
const ruleNames = new Set(selected.rules.map(rule => rule.name))
|
|
672
|
-
await syncProjectAgentsMd(projectDir, scans.rules.filter(rule => ruleNames.has(rule.name)), { preservedRuleNames })
|
|
673
|
-
}
|
|
674
|
-
if (selectedPlatforms.includes('claude')) {
|
|
675
|
-
await removeLegacyBaseline(baseline, path.join(homeDir, '.claude', 'AGENTS.md'))
|
|
676
|
-
for (const name of ['AGENTS.md', 'CLAUDE.md']) await removeLegacyBaseline(baseline, path.join(projectDir, '.claude', name))
|
|
677
|
-
for (const dest of legacyClaudeRules) {
|
|
678
|
-
await fs.remove(dest)
|
|
679
|
-
logAction(`legacy rule → ${dest}`, 'red', '×')
|
|
680
|
-
}
|
|
681
|
-
syncedAnySupportedPlatform = true
|
|
682
|
-
console.log(chalk.blue('\nSyncing Claude Code configuration...\n'))
|
|
683
|
-
await syncClaude({
|
|
684
|
-
sourceDir,
|
|
685
|
-
projectDir: targetRoot,
|
|
686
|
-
global,
|
|
687
|
-
selected: selectionFor('claude'),
|
|
688
|
-
homeDir,
|
|
689
|
-
sharedSkills,
|
|
690
|
-
staleRemovals: approvedStaleRemovals.claude,
|
|
691
|
-
})
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (selectedPlatforms.includes('codex')) {
|
|
695
|
-
syncedAnySupportedPlatform = true
|
|
696
|
-
console.log(chalk.blue('\nSyncing Codex configuration...\n'))
|
|
697
|
-
await syncCodex({
|
|
698
|
-
sourceDir,
|
|
699
|
-
projectDir: targetRoot,
|
|
700
|
-
global,
|
|
701
|
-
selected: selectionFor('codex'),
|
|
702
|
-
homeDir,
|
|
703
|
-
sharedSkills,
|
|
704
|
-
staleRemovals: approvedStaleRemovals.codex,
|
|
705
|
-
})
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (selectedPlatforms.includes('cursor')) {
|
|
709
|
-
syncedAnySupportedPlatform = true
|
|
710
|
-
await syncCursor({
|
|
711
|
-
sourceDir,
|
|
712
|
-
projectDir,
|
|
713
|
-
selected,
|
|
714
|
-
staleRemovals: approvedStaleRemovals.cursor,
|
|
715
|
-
})
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
if (selectedPlatforms.includes('opencode')) {
|
|
719
|
-
syncedAnySupportedPlatform = true
|
|
720
|
-
await syncOpenCode({
|
|
721
|
-
sourceDir,
|
|
722
|
-
projectDir: targetRoot,
|
|
723
|
-
global,
|
|
724
|
-
selected: selectionFor('opencode'),
|
|
725
|
-
homeDir,
|
|
726
|
-
sharedSkills,
|
|
727
|
-
staleRemovals: approvedStaleRemovals.opencode,
|
|
728
|
-
})
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
if (global) {
|
|
735
|
-
for (const platform of selectedPlatforms) {
|
|
736
|
-
duplicateNames[platform] = await deduplicateProjectSkills({ projectDir, platform, skills: sharedSkills, homeDir, codexHome })
|
|
737
|
-
if (syncState.platforms[platform]) {
|
|
738
|
-
syncState.platforms[platform].skills = syncState.platforms[platform].skills.filter(name => !duplicateNames[platform].has(name))
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (syncMode === SYNC_MODE_SKILLS) {
|
|
744
|
-
for (const platform of selectedPlatforms) {
|
|
745
|
-
const previous = targetState.platforms[platform] || createEmptyPlatformState()
|
|
746
|
-
const skillsOnlySelected = { skills: (global ? selected : selectionFor(platform)).skills, rules: [], commands: [], hooks: [] }
|
|
747
|
-
targetState.platforms[platform] = {
|
|
748
|
-
...previous,
|
|
749
|
-
skills: buildNextPlatformState(skillsOnlySelected, staleEntries, approvedStaleRemovals, platform).skills,
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
} else {
|
|
753
|
-
for (const platform of selectedPlatforms) {
|
|
754
|
-
const next = buildNextPlatformState(global ? selected : selectionFor(platform), staleEntries, approvedStaleRemovals, platform)
|
|
755
|
-
if (global) {
|
|
756
|
-
syncState.platforms[platform] = {
|
|
757
|
-
...(syncState.platforms[platform] || createEmptyPlatformState()),
|
|
758
|
-
rules: next.rules,
|
|
759
|
-
}
|
|
760
|
-
if (platform !== 'cursor') targetState.platforms[platform] = { ...next, rules: [] }
|
|
761
|
-
} else {
|
|
762
|
-
targetState.platforms[platform] = next
|
|
763
|
-
}
|
|
764
|
-
if (platform !== 'cursor') {
|
|
765
|
-
globalState.platforms[platform] = { ...globalState.platforms[platform], rules: [] }
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
if (syncMode === SYNC_MODE_ALL && cliPlatforms.length) {
|
|
770
|
-
const agentsPath = path.join(projectDir, 'AGENTS.md')
|
|
771
|
-
const content = await fs.pathExists(agentsPath) ? await fs.readFile(agentsPath, 'utf-8') : ''
|
|
772
|
-
const rules = [...parseManagedRuleNames(content)].sort((a, b) => a.localeCompare(b, 'zh'))
|
|
773
|
-
// 三个 CLI 共用同一规则块,已有记录必须一起更新,避免切换平台后恢复已取消的规则。
|
|
774
|
-
for (const platform of Object.keys(baselinePaths)) {
|
|
775
|
-
if (syncState.platforms[platform]) syncState.platforms[platform].rules = rules
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
targetState.skillScope = skillScope
|
|
779
|
-
targetState.syncMode = syncMode
|
|
780
|
-
if (global) {
|
|
781
|
-
syncState.syncMode = syncMode
|
|
782
|
-
await saveSyncState(projectDir, syncState)
|
|
783
|
-
if (cliPlatforms.length) await saveSyncState(homeDir, targetState)
|
|
784
|
-
} else {
|
|
785
|
-
await saveSyncState(targetRoot, targetState)
|
|
786
|
-
if (syncMode === SYNC_MODE_ALL && cliPlatforms.length && baseline.trim()) {
|
|
787
|
-
await saveSyncState(homeDir, globalState)
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
if (!syncedAnySupportedPlatform) {
|
|
792
|
-
console.log(chalk.yellow('未选择已接入平台,本次未执行同步。'))
|
|
793
|
-
}
|
|
300
|
+
for (const operation of plan.operations) await operation.run()
|
|
301
|
+
console.log(chalk.green('同步完成。'))
|
|
302
|
+
return plan.operations.map(({ kind, label, paths }) => ({ kind, label, paths }))
|
|
794
303
|
}
|