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/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
- copySkillsToGlobalDirs,
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
- interactiveSelect,
20
- logAction,
21
- readBaselineDoc,
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: '支持 rules / skills / hooks' },
45
- { value: 'codex', label: 'Codex', description: '支持 rules / skills / hooks' },
46
- { value: 'cursor', label: 'Cursor', description: '支持 rules' },
47
- { 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' },
48
21
  ]
49
-
50
22
  const SYNC_STATE_PATH = ['.dwy', 'sync-state.json']
51
- const PLATFORM_SYNC_TYPES = {
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
- /** 仅 Skills:不写、不删 rules / commands / hooks */
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
- /** 强制刷新 ~/.dwy/skills 外部 skill */
30
+ /** 安装产品流程依赖的外部 Skills;仅供已有脚本调用。 */
72
31
  export const ACTION_INSTALL_SKILLS = 'install-skills'
73
- /** 把全局 create-dwy 升到 npm latest */
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
- function hasSetItems(setsByType) {
103
- return Object.values(setsByType).some(items => items.size > 0)
104
- }
105
-
106
- export function buildSelectionDefaultsFromSyncState(existing, syncState) {
107
- const stateDefaults = {
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
- export async function buildPlatformDefaultsFromLocalState(projectDir) {
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, '.claude'))) {
151
- defaults.push('claude')
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
- defaults.push('codex')
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
- initialValues,
228
- required: true,
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
- if (isCancel(result)) return null
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(projectDir) {
250
- const statePath = path.join(projectDir, ...SYNC_STATE_PATH)
251
- if (!await fs.pathExists(statePath)) {
252
- return {
253
- version: 1,
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(projectDir, state) {
270
- const statePath = path.join(projectDir, ...SYNC_STATE_PATH)
271
- await fs.ensureDir(path.dirname(statePath))
272
- 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 })
273
100
  }
274
101
 
275
- function getCurrentTemplateNames(scans) {
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 collectCurrentManagedNames(projectDir, global = false) {
285
- const claudeDir = path.join(projectDir, '.claude')
286
- const openCodeDir = global ? path.join(projectDir, '.config', 'opencode') : path.join(projectDir, '.opencode')
287
- const agentsMdPath = global ? path.join(projectDir, '.codex', 'AGENTS.md') : path.join(projectDir, 'AGENTS.md')
288
- const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
289
-
290
- return {
291
- claude: {
292
- skills: await scanExisting(claudeDir, 'skills'),
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
- function normalizeRemovalInput(removals = {}) {
312
- const normalized = {}
313
- for (const [platform, types] of Object.entries(removals)) {
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
- async function collectStaleEntries(projectDir, scans, syncState, selectedPlatforms, global = false) {
329
- const currentTemplateNames = getCurrentTemplateNames(scans)
330
- const currentManaged = await collectCurrentManagedNames(projectDir, global)
331
- const staleEntries = createEmptyRemovalState()
332
-
333
- for (const platform of selectedPlatforms) {
334
- const previous = syncState.platforms?.[platform] || {}
335
- const platformManaged = currentManaged[platform] || {}
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 getPreservedStaleNames(staleEntries, approvedRemovals, platform, type) {
374
- const staleNames = staleEntries[platform]?.[type] || []
375
- const approvedNames = approvedRemovals[platform]?.[type] || new Set()
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
- function buildNextPlatformState(selected, staleEntries, approvedRemovals, platform) {
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
- await syncAll(opts)
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 prepareSharedSkills({ skills: [...neededNames].map(name => available.get(name)), homeDir })
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
- projectDir: projectDirOverride,
491
- selected: selectedOverride,
492
- selectedPlatforms: selectedPlatformsOverride,
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
- const scans = {
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 / Hooks 写入项目;基线固定全局,Rules 固定项目' },
542
- { value: 'global', label: '全局', description: 'Skills / Hooks 写入用户目录;基线固定全局,Rules 固定项目' },
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(scopeResult)) { console.log(chalk.yellow('已取消同步。')); return }
547
- if (!['project', 'global'].includes(scopeResult)) throw new Error('无效同步位置')
548
- const global = scopeResult === 'global'
549
- const targetRoot = global ? homeDir : projectDir
550
- const targetState = global ? globalState : syncState
551
- const selectedPlatforms = selectedPlatformsOverride || await selectPlatforms(projectDir)
552
- if (selectedPlatforms === null) { console.log(chalk.yellow('已取消同步。')); return }
553
- if (selectedPlatforms.some(platform => !PLATFORM_SYNC_TYPES[platform])) {
554
- throw new Error('所选平台不支持此同步位置')
555
- }
556
- const projectStaleEntries = await collectStaleEntries(projectDir, scans, syncState, selectedPlatforms)
557
- const staleEntriesRaw = global
558
- ? await collectStaleEntries(targetRoot, scans, targetState, selectedPlatforms, true)
559
- : projectStaleEntries
560
- for (const platform of selectedPlatforms) {
561
- staleEntriesRaw[platform].rules = projectStaleEntries[platform].rules || []
562
- }
563
- const staleEntries = syncMode === SYNC_MODE_SKILLS ? retainSkillStaleEntries(staleEntriesRaw) : staleEntriesRaw
564
- const approvedStaleRemovals = normalizeRemovalInput(staleRemovalsOverride || staleEntries)
565
- const skillScope = normalizeSkillScope(skillScopeOverride)
566
- const baseline = syncMode === SYNC_MODE_ALL ? await readBaselineDoc(sourceDir) : ''
567
- const cliPlatforms = selectedPlatforms.filter(platform => platform !== 'cursor')
568
- const baselinePaths = {
569
- claude: path.join(homeDir, '.claude', 'CLAUDE.md'),
570
- codex: path.join(codexHome, 'AGENTS.md'),
571
- opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md'),
572
- }
573
- const legacyClaudeRules = new Set()
574
- if (syncMode === SYNC_MODE_ALL && selectedPlatforms.includes('claude')) {
575
- for (const [root, previousNames] of [
576
- [projectDir, approvedStaleRemovals.claude?.rules || []],
577
- ...(baseline.trim() ? [[homeDir, globalState.platforms.claude?.rules || []]] : []),
578
- ]) {
579
- for (const name of new Set([...scans.rules.map(rule => rule.name), ...previousNames])) {
580
- if (path.basename(name) !== name || !name.endsWith('.md')) continue
581
- const dest = path.join(root, '.claude', 'rules', name)
582
- if (await fs.pathExists(dest)) legacyClaudeRules.add(dest)
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
- if (!selectedOverride && selected.skills.some(item => item.category === '产品0到1')) await ensureSkillsInstalled()
619
-
620
- const selectedSkillNames = new Set((selected.skills || []).map(item => item.name))
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
  }