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