create-dwy 0.20.0 → 0.22.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 (46) 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 +18 -8
  5. package/src/self-upgrade.js +7 -0
  6. package/src/skill-store.js +182 -0
  7. package/src/sync-all.js +232 -51
  8. package/src/sync-codex.js +47 -30
  9. package/src/sync-cursor.js +0 -25
  10. package/src/sync-global-skills.js +65 -14
  11. package/src/sync-opencode.js +10 -18
  12. package/src/sync.js +23 -92
  13. package/src/utils.js +0 -1
  14. package/templates/ai-tools/{CLAUDE.md → AGENTS.md} +33 -1
  15. package/templates/ai-tools/CHANGELOG.md +6 -0
  16. package/templates/ai-tools/VERSION +1 -1
  17. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/progr/sql/createExternalTable.md +2 -2
  18. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tools/superset.md +1 -1
  19. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tutorials/Migrate_data_from_InfluxDB_to_DolphinDB.md +1 -1
  20. package/templates/ai-tools/skills/Python/dwy-eapi/references/tasks-integration-guide.md +4 -4
  21. package/templates/ai-tools/skills/Vue/dwy-eui/references/eui-integration-guide.md +12 -0
  22. package/templates/ai-tools/skills/Vue/dwy-eui/shared/config-theme.md +2 -0
  23. package/templates/ai-tools/skills/Vue/dwy-eui/shared/form.md +10 -0
  24. package/templates/ai-tools/skills/Vue/dwy-eui/v2/index.md +5 -0
  25. package/templates/ai-tools/skills//344/272/247/345/223/2010/345/210/2601/dwy-stage-dev/SKILL.md +1 -1
  26. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +18 -25
  27. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +3 -3
  28. 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
  29. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +6 -70
  30. 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
  31. 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
  32. 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
  33. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +2 -2
  34. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +3 -3
  35. 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
  36. 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
  37. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +3 -7
  38. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/SKILL.md +58 -0
  39. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/delegation-guide.md +46 -0
  40. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/review-guide.md +57 -0
  41. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-shared/SKILL.md +1 -1
  42. package/templates/project/fullstack-monorepo/{CLAUDE.md.hbs → AGENTS.md.hbs} +1 -1
  43. /package/templates/project/backend/{CLAUDE.md.hbs → AGENTS.md.hbs} +0 -0
  44. /package/templates/project/fullstack-monorepo/backend/{CLAUDE.md.hbs → AGENTS.md.hbs} +0 -0
  45. /package/templates/project/fullstack-monorepo/frontend/{CLAUDE.md.hbs → AGENTS.md.hbs} +0 -0
  46. /package/templates/project/web/{CLAUDE.md.hbs → AGENTS.md.hbs} +0 -0
package/src/sync-all.js CHANGED
@@ -8,11 +8,17 @@ import {
8
8
  copySkillsToProjectPlatforms,
9
9
  normalizeSkillScope,
10
10
  resolveProjectSkillDir,
11
+ resolveGlobalSkillDir,
12
+ deduplicateProjectSkills,
13
+ findGlobalSkill,
14
+ scanGlobalSkills,
11
15
  } from './sync-global-skills.js'
16
+ import { prepareSharedSkills, removeSkillEntry } from './skill-store.js'
12
17
  import { chalk } from './utils.js'
13
18
  import {
14
19
  interactiveSelect,
15
20
  logAction,
21
+ readBaselineDoc,
16
22
  resolveSourceDir,
17
23
  scanExisting,
18
24
  scanHooks,
@@ -22,9 +28,12 @@ import {
22
28
  } from './sync.js'
23
29
  import {
24
30
  parseManagedRuleNames,
31
+ removeLegacyBaseline,
25
32
  scanExistingCodexHooks,
26
33
  scanExistingCodexSkills,
27
34
  syncCodex,
35
+ syncGlobalBaselineDoc,
36
+ syncProjectAgentsMd,
28
37
  } from './sync-codex.js'
29
38
  import { scanExistingCursorRules, syncCursor } from './sync-cursor.js'
30
39
  import { syncOpenCode } from './sync-opencode.js'
@@ -113,7 +122,7 @@ export function buildSelectionDefaultsFromSyncState(existing, syncState) {
113
122
  return hasSetItems(stateDefaults) ? stateDefaults : existing
114
123
  }
115
124
 
116
- async function scanCombinedExisting(projectDir) {
125
+ async function scanCombinedExisting(projectDir, global = false) {
117
126
  const claudeDir = path.join(projectDir, '.claude')
118
127
  const agentsMdPath = path.join(projectDir, 'AGENTS.md')
119
128
  const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
@@ -122,6 +131,7 @@ async function scanCombinedExisting(projectDir) {
122
131
  skills: unionSets(
123
132
  await scanExisting(claudeDir, 'skills'),
124
133
  await scanExistingCodexSkills(projectDir),
134
+ await scanExisting(path.join(projectDir, ...(global ? ['.config', 'opencode'] : ['.opencode'])), 'skills'),
125
135
  ),
126
136
  rules: unionSets(
127
137
  await scanExisting(claudeDir, 'rules'),
@@ -170,6 +180,9 @@ async function syncSkillsOnly({
170
180
  templateSkillNames,
171
181
  staleSkillNames,
172
182
  skillScope,
183
+ homeDir,
184
+ global,
185
+ duplicateNames,
173
186
  }) {
174
187
  const writeProject = skillScope.destinations.includes('project')
175
188
  if (!writeProject) return
@@ -181,37 +194,37 @@ async function syncSkillsOnly({
181
194
  }
182
195
 
183
196
  console.log(chalk.blue('\nSyncing skills only (rules / commands / hooks untouched)...\n'))
184
- await copySkillsToProjectPlatforms({
185
- projectDir,
186
- platforms: skillPlatforms,
187
- skills,
188
- })
189
-
190
- const selectedNames = new Set(skills.map(item => item.name))
191
- const toRemove = new Set(
192
- [...templateSkillNames, ...staleSkillNames].filter(name => !selectedNames.has(name)),
193
- )
194
197
  for (const platform of skillPlatforms) {
195
- const destRoot = resolveProjectSkillDir(projectDir, platform)
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)
196
209
  const existingNames = await scanExisting(path.dirname(destRoot), 'skills')
197
210
  await removeManagedItems(
198
211
  destRoot,
199
212
  existingNames,
200
213
  toRemove,
201
214
  path.relative(projectDir, destRoot),
215
+ path.join(global ? homeDir : projectDir, '.dwy', 'skill-backups'),
202
216
  )
203
217
  }
204
218
  }
205
219
 
206
- async function selectPlatforms(projectDir, global) {
220
+ async function selectPlatforms(projectDir) {
207
221
  const initialValues = await buildPlatformDefaultsFromLocalState(projectDir)
208
222
 
209
- if (global) console.log(chalk.gray('Cursor 全局规则通过应用设置管理,本次不提供文件同步。'))
210
223
  // 可搜索多选 + 底部说明区
211
224
  const result = await searchableMultiselect({
212
225
  message: '选择要同步的平台',
213
- options: PLATFORM_OPTIONS.filter(option => !global || option.value !== 'cursor'),
214
- initialValues: initialValues.filter(value => !global || value !== 'cursor'),
226
+ options: PLATFORM_OPTIONS,
227
+ initialValues,
215
228
  required: true,
216
229
  maxItems: 6,
217
230
  placeholder: SEARCH_PLACEHOLDER,
@@ -277,7 +290,7 @@ async function collectCurrentManagedNames(projectDir, global = false) {
277
290
  return {
278
291
  claude: {
279
292
  skills: await scanExisting(claudeDir, 'skills'),
280
- rules: await scanExisting(claudeDir, 'rules'),
293
+ rules: unionSets(await scanExisting(claudeDir, 'rules'), parseManagedRuleNames(agentsMdContent)),
281
294
  hooks: await scanExisting(claudeDir, 'hooks'),
282
295
  },
283
296
  codex: {
@@ -378,12 +391,12 @@ function buildNextPlatformState(selected, staleEntries, approvedRemovals, platfo
378
391
  return state
379
392
  }
380
393
 
381
- async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix) {
394
+ async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix, backupRoot) {
382
395
  let removedCount = 0
383
396
 
384
397
  for (const name of existingNames) {
385
398
  if (!managedNames.has(name)) continue
386
- await fs.remove(path.join(targetDir, name))
399
+ await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot })
387
400
  logAction(`${labelPrefix}/${name}`, 'red', '×')
388
401
  removedCount++
389
402
  }
@@ -416,18 +429,75 @@ export async function runDwy(opts = {}) {
416
429
  await syncAll(opts)
417
430
  }
418
431
 
432
+ /**
433
+ * 一键迁移当前项目已有的 CLI Skills,不进入选择流程、不补装其他平台、不修改 Rules / Hooks。
434
+ * 仅当前模板或历史同步记录中的名称视为托管;已移除的历史模板和整个根目录链接保留并报告。
435
+ */
436
+ export async function migrateSkills({
437
+ sourceDir,
438
+ projectDir = process.cwd(),
439
+ homeDir = os.homedir(),
440
+ codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
441
+ } = {}) {
442
+ const scans = await scanSkills(sourceDir || await resolveSourceDir())
443
+ const available = new Map(scans.map(skill => [skill.name, skill]))
444
+ const state = await loadSyncState(projectDir)
445
+ const existingByPlatform = {}
446
+ const neededNames = new Set()
447
+ const retained = []
448
+ const realProject = await fs.realpath(projectDir)
449
+ for (const platform of ['claude', 'codex', 'opencode']) {
450
+ const root = resolveProjectSkillDir(projectDir, platform)
451
+ if (!await fs.pathExists(root)) continue
452
+ if (await fs.realpath(root) !== path.join(realProject, path.relative(projectDir, root))) {
453
+ console.log(chalk.yellow(`${root} 已通过根目录链接共享内容,保留原链接,不迁移其目标。`))
454
+ continue
455
+ }
456
+ const existing = await scanExisting(path.dirname(root), 'skills')
457
+ const historical = new Set(state.platforms[platform]?.skills || [])
458
+ const managed = [...existing].filter(name => available.has(name) || historical.has(name))
459
+ if (managed.length === 0) continue
460
+ existingByPlatform[platform] = managed
461
+ for (const name of managed) {
462
+ if (available.has(name)) neededNames.add(name)
463
+ else {
464
+ retained.push({ platform, name })
465
+ console.log(chalk.yellow(`${platform}/${name} 已不在当前模板中,保留原目录。`))
466
+ }
467
+ }
468
+ }
469
+ const shared = await prepareSharedSkills({ skills: [...neededNames].map(name => available.get(name)), homeDir })
470
+ let linked = 0
471
+ let globalCount = 0
472
+ for (const [platform, names] of Object.entries(existingByPlatform)) {
473
+ const skills = shared.filter(skill => names.includes(skill.name))
474
+ const duplicates = await deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome })
475
+ const local = skills.filter(skill => !duplicates.has(skill.name))
476
+ linked += await copySkillsToProjectPlatforms({ projectDir, platforms: [platform], skills: local, homeDir })
477
+ globalCount += duplicates.size
478
+ state.platforms[platform] = {
479
+ ...(state.platforms[platform] || createEmptyPlatformState()),
480
+ skills: names.filter(name => !duplicates.has(name)).sort(),
481
+ }
482
+ }
483
+ if (Object.keys(existingByPlatform).length) await saveSyncState(projectDir, state)
484
+ console.log(chalk.green(`迁移完成:${linked} 个项目链接,${globalCount} 个使用全局入口,${retained.length} 个历史条目保留。`))
485
+ return { linked, global: globalCount, retained }
486
+ }
487
+
419
488
  export async function syncAll({
420
489
  sourceDir: sourceDirOverride,
421
490
  projectDir: projectDirOverride,
422
491
  selected: selectedOverride,
423
492
  selectedPlatforms: selectedPlatformsOverride,
424
493
  staleRemovals: staleRemovalsOverride,
425
- // 兼容已有脚本;主交互只选择整套配置的位置。
494
+ // 兼容已有脚本;主交互的位置只控制 Skills / Hooks。
426
495
  skillScope: skillScopeOverride,
427
496
  // all = 完整同步;skills = 只写 skill,测试/脚本可注入
428
497
  syncMode: syncModeOverride,
429
498
  // 全局配置根目录;测试必须注入临时用户目录。
430
499
  homeDir = os.homedir(),
500
+ codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
431
501
  scope: scopeOverride,
432
502
  confirmed,
433
503
  } = {}) {
@@ -448,8 +518,12 @@ export async function syncAll({
448
518
  const syncState = await loadSyncState(projectDir)
449
519
  const globalState = await loadSyncState(homeDir)
450
520
  const projectDefaults = buildSelectionDefaultsFromSyncState(existing, syncState)
451
- const globalDefaults = buildSelectionDefaultsFromSyncState({ skills: new Set(), rules: new Set(), hooks: new Set() }, globalState)
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)
452
525
  const selectionDefaults = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, unionSets(projectDefaults[type], globalDefaults[type])]))
526
+ selectionDefaults.rules = projectDefaults.rules
453
527
  const syncMode = normalizeSyncMode(syncModeOverride)
454
528
  const selected = selectedOverride || await interactiveSelect(scans, selectionDefaults, {
455
529
  skillsOnly: syncMode === SYNC_MODE_SKILLS,
@@ -462,10 +536,10 @@ export async function syncAll({
462
536
  if (syncMode === SYNC_MODE_ALL) selected.hooks = scans.hooks
463
537
 
464
538
  const scopeResult = scopeOverride || (selectedOverride || selectedPlatformsOverride ? 'project' : await searchableSelect({
465
- message: '选择同步位置',
539
+ message: '选择同步位置(Skills / Hooks)',
466
540
  options: [
467
- { value: 'project', label: '当前项目', description: '仅当前项目生效' },
468
- { value: 'global', label: '全局', description: '所有项目生效' },
541
+ { value: 'project', label: '当前项目', description: 'Skills / Hooks 写入项目;基线固定全局,Rules 固定项目' },
542
+ { value: 'global', label: '全局', description: 'Skills / Hooks 写入用户目录;基线固定全局,Rules 固定项目' },
469
543
  ],
470
544
  initialValue: 'project',
471
545
  }))
@@ -473,27 +547,63 @@ export async function syncAll({
473
547
  if (!['project', 'global'].includes(scopeResult)) throw new Error('无效同步位置')
474
548
  const global = scopeResult === 'global'
475
549
  const targetRoot = global ? homeDir : projectDir
476
- const targetState = global ? await loadSyncState(homeDir) : syncState
477
- const selectedPlatforms = selectedPlatformsOverride || await selectPlatforms(targetRoot, global)
550
+ const targetState = global ? globalState : syncState
551
+ const selectedPlatforms = selectedPlatformsOverride || await selectPlatforms(projectDir)
478
552
  if (selectedPlatforms === null) { console.log(chalk.yellow('已取消同步。')); return }
479
- if (selectedPlatforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))) {
553
+ if (selectedPlatforms.some(platform => !PLATFORM_SYNC_TYPES[platform])) {
480
554
  throw new Error('所选平台不支持此同步位置')
481
555
  }
482
- const staleEntriesRaw = await collectStaleEntries(targetRoot, scans, targetState, selectedPlatforms, global)
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
+ }
483
563
  const staleEntries = syncMode === SYNC_MODE_SKILLS ? retainSkillStaleEntries(staleEntriesRaw) : staleEntriesRaw
484
564
  const approvedStaleRemovals = normalizeRemovalInput(staleRemovalsOverride || staleEntries)
485
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
+ }
585
+ }
486
586
  if ((!selectedOverride && confirmed !== true) || confirmed === false) {
487
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
488
590
  const templateNames = getCurrentTemplateNames(scans)
489
591
  console.log(`\nSkills:${selected.skills.map(item => item.name).join('、') || '未选择'}`)
490
592
  console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '未选择'}`)
491
- console.log(`位置:${global ? '全局' : '当前项目'};平台:${selectedPlatforms.map(platform => PLATFORM_LABELS[platform]).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
+ }
492
600
  console.log('Hooks 按支持的平台自动启用。')
601
+ console.log('Skills 将迁为用户共享副本的目录链接;原真实目录先备份,同平台全局入口优先。')
493
602
  for (const platform of selectedPlatforms) {
494
603
  for (const type of PLATFORM_SYNC_TYPES[platform]) {
495
604
  const existingNames = currentManaged[platform]?.[type] || new Set()
496
- const previous = [...new Set([...templateNames[type], ...(targetState.platforms[platform]?.[type] || [])])]
605
+ const previousState = type === 'rules' ? syncState : targetState
606
+ const previous = [...new Set([...templateNames[type], ...(previousState.platforms[platform]?.[type] || [])])]
497
607
  .filter(name => existingNames.has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
498
608
  const names = new Set((selected[type] || []).map(item => item.name))
499
609
  const removed = previous.filter(name => !names.has(name))
@@ -508,7 +618,32 @@ export async function syncAll({
508
618
  if (!selectedOverride && selected.skills.some(item => item.category === '产品0到1')) await ensureSkillsInstalled()
509
619
 
510
620
  const selectedSkillNames = new Set((selected.skills || []).map(item => item.name))
511
- const skillsToWrite = scans.skills.filter(item => selectedSkillNames.has(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
+ })
512
647
  let syncedAnySupportedPlatform = false
513
648
 
514
649
  if (syncMode === SYNC_MODE_SKILLS) {
@@ -523,18 +658,35 @@ export async function syncAll({
523
658
  templateSkillNames: new Set(scans.skills.map(item => item.name)),
524
659
  staleSkillNames,
525
660
  skillScope,
661
+ homeDir,
662
+ global,
663
+ duplicateNames,
526
664
  })
527
665
  syncedAnySupportedPlatform = selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))
528
666
  || skillScope.destinations.some(id => id !== 'project')
529
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
+ }
530
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
+ }
531
681
  syncedAnySupportedPlatform = true
532
682
  console.log(chalk.blue('\nSyncing Claude Code configuration...\n'))
533
683
  await syncClaude({
534
684
  sourceDir,
535
685
  projectDir: targetRoot,
536
686
  global,
537
- selected,
687
+ selected: selectionFor('claude'),
688
+ homeDir,
689
+ sharedSkills,
538
690
  staleRemovals: approvedStaleRemovals.claude,
539
691
  })
540
692
  }
@@ -546,9 +698,10 @@ export async function syncAll({
546
698
  sourceDir,
547
699
  projectDir: targetRoot,
548
700
  global,
549
- selected,
701
+ selected: selectionFor('codex'),
702
+ homeDir,
703
+ sharedSkills,
550
704
  staleRemovals: approvedStaleRemovals.codex,
551
- preserveMissingRules: getPreservedStaleNames(staleEntries, approvedStaleRemovals, 'codex', 'rules'),
552
705
  })
553
706
  }
554
707
 
@@ -556,8 +709,7 @@ export async function syncAll({
556
709
  syncedAnySupportedPlatform = true
557
710
  await syncCursor({
558
711
  sourceDir,
559
- projectDir: targetRoot,
560
- global,
712
+ projectDir,
561
713
  selected,
562
714
  staleRemovals: approvedStaleRemovals.cursor,
563
715
  })
@@ -569,30 +721,29 @@ export async function syncAll({
569
721
  sourceDir,
570
722
  projectDir: targetRoot,
571
723
  global,
572
- selected,
724
+ selected: selectionFor('opencode'),
725
+ homeDir,
726
+ sharedSkills,
573
727
  staleRemovals: approvedStaleRemovals.opencode,
574
- preserveMissingRules: getPreservedStaleNames(staleEntries, approvedStaleRemovals, 'opencode', 'rules'),
575
728
  })
576
729
  }
577
730
 
578
731
 
579
732
  }
580
733
 
581
- const globalSkillNameSet = new Set(skillScope.globalSkills)
582
- const globalSkills = scans.skills.filter(item => globalSkillNameSet.has(item.name))
583
- if (globalSkills.length > 0 && skillScope.destinations.some(id => id !== 'project')) {
584
- console.log(chalk.blue('\nSyncing selected skills to global directories...\n'))
585
- await copySkillsToGlobalDirs({
586
- skills: globalSkills,
587
- destIds: skillScope.destinations,
588
- homeDir,
589
- })
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
+ }
590
741
  }
591
742
 
592
743
  if (syncMode === SYNC_MODE_SKILLS) {
593
744
  for (const platform of selectedPlatforms) {
594
- const previous = syncState.platforms[platform] || createEmptyPlatformState()
595
- const skillsOnlySelected = { skills: selected.skills || [], rules: [], commands: [], hooks: [] }
745
+ const previous = targetState.platforms[platform] || createEmptyPlatformState()
746
+ const skillsOnlySelected = { skills: (global ? selected : selectionFor(platform)).skills, rules: [], commands: [], hooks: [] }
596
747
  targetState.platforms[platform] = {
597
748
  ...previous,
598
749
  skills: buildNextPlatformState(skillsOnlySelected, staleEntries, approvedStaleRemovals, platform).skills,
@@ -600,12 +751,42 @@ export async function syncAll({
600
751
  }
601
752
  } else {
602
753
  for (const platform of selectedPlatforms) {
603
- targetState.platforms[platform] = buildNextPlatformState(selected, staleEntries, approvedStaleRemovals, platform)
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
604
776
  }
605
777
  }
606
778
  targetState.skillScope = skillScope
607
779
  targetState.syncMode = syncMode
608
- await saveSyncState(targetRoot, targetState)
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
+ }
609
790
 
610
791
  if (!syncedAnySupportedPlatform) {
611
792
  console.log(chalk.yellow('未选择已接入平台,本次未执行同步。'))
package/src/sync-codex.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import fs from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { chalk } from './utils.js'
4
+ import { linkSkill, prepareSharedSkills, removeSkillEntry } from './skill-store.js'
4
5
  import {
5
6
  extractDescription,
6
7
  loadHookManifests,
7
- readBaselineDoc,
8
8
  scanSkills,
9
- scanRules,
10
9
  scanHooks,
11
10
  resolveSourceDir,
12
11
  copyFilter,
@@ -112,13 +111,37 @@ function upsertBaselineBlock(content, baselineBody) {
112
111
  return base ? `${block}\n\n${base}` : `${block}\n`
113
112
  }
114
113
 
115
- export async function syncProjectAgentsMd(sourceDir, projectDir, selectedRules, {
114
+ /** 更新 CLI 全局基线,仅替换 dwy 托管块,并清除旧版写入的项目 Rules 托管块。 */
115
+ export async function syncGlobalBaselineDoc(baseline, dest) {
116
+ if (!baseline.trim()) return 0
117
+ const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
118
+ const previous = current.trim() === baseline.trim() ? '' : current
119
+ const next = upsertManagedBlock(upsertBaselineBlock(previous, baseline), '')
120
+ if (next === current) return 0
121
+ await fs.ensureDir(path.dirname(dest))
122
+ await fs.writeFile(dest, next)
123
+ logAction(`global baseline → ${dest}`)
124
+ return 1
125
+ }
126
+
127
+ /** 迁移旧入口中的基线;无托管标记时只移除与当前模板完全一致的文件。 */
128
+ export async function removeLegacyBaseline(baseline, dest) {
129
+ if (!await fs.pathExists(dest)) return
130
+ if (!baseline.trim()) return
131
+ const current = await fs.readFile(dest, 'utf-8')
132
+ const next = current.trim() === baseline.trim() ? '' : upsertBaselineBlock(current, '')
133
+ if (next === current) return
134
+ if (next.trim()) await fs.writeFile(dest, next)
135
+ else await fs.remove(dest)
136
+ logAction(`legacy baseline → ${dest}`, 'red', '×')
137
+ }
138
+
139
+ /** 三种 CLI 共用项目根 AGENTS.md;只写所选 Rules,保留托管块之外的项目内容。 */
140
+ export async function syncProjectAgentsMd(projectDir, selectedRules, {
116
141
  preservedRuleNames = new Set(),
117
- targetPath = path.join(projectDir, 'AGENTS.md'),
118
142
  } = {}) {
119
- const dest = targetPath
143
+ const dest = path.join(projectDir, 'AGENTS.md')
120
144
  const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
121
- const baselineBody = await readBaselineDoc(sourceDir)
122
145
  const selectedRuleNames = new Set(selectedRules.map(rule => rule.name))
123
146
  const existingSections = parseManagedRuleSections(current)
124
147
  const sections = []
@@ -130,15 +153,11 @@ export async function syncProjectAgentsMd(sourceDir, projectDir, selectedRules,
130
153
  }
131
154
  const blockBody = sections.join('\n\n---\n\n')
132
155
 
133
- const withBaseline = upsertBaselineBlock(current, baselineBody)
134
- const next = upsertManagedBlock(withBaseline, blockBody)
156
+ const next = upsertManagedBlock(upsertBaselineBlock(current, ''), blockBody)
135
157
  if (next === current) return 0
136
158
  await fs.ensureDir(path.dirname(dest))
137
159
  await fs.writeFile(dest, next)
138
- const parts = []
139
- if (baselineBody.trim()) parts.push('baseline')
140
- parts.push(blockBody.trim() ? `${selectedRules.length} rules` : 'rules removed')
141
- logAction(`AGENTS.md — ${parts.join(', ')}`)
160
+ logAction(`AGENTS.md — ${blockBody.trim() ? `${selectedRules.length} rules` : 'rules removed'}`)
142
161
  return 1
143
162
  }
144
163
 
@@ -168,15 +187,14 @@ export async function scanExistingCodexSkills(proj) {
168
187
  const dir = path.join(proj, '.agents', 'skills')
169
188
  if (!await fs.pathExists(dir)) return new Set()
170
189
  const entries = await fs.readdir(dir, { withFileTypes: true })
171
- return new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
190
+ return new Set(entries.filter(e => e.isDirectory() || e.isSymbolicLink()).map(e => e.name))
172
191
  }
173
192
 
174
193
  async function copySkillsFlat(skills, proj) {
175
194
  const root = path.join(proj, '.agents', 'skills')
176
195
  for (const s of skills) {
177
196
  const dest = path.join(root, s.name)
178
- await fs.ensureDir(path.dirname(dest))
179
- await fs.copy(s.sourcePath, dest, { overwrite: true, filter: copyFilter })
197
+ await linkSkill({ skill: s, dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
180
198
  logAction(`.agents/skills/${s.name}`)
181
199
  }
182
200
  return skills.length
@@ -188,7 +206,12 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
188
206
  if (selectedNames.has(name)) continue
189
207
  // 项目独有(模板未提供过)的条目永不删除,保留用户自定义
190
208
  if (!templateNames.has(name)) continue
191
- await fs.remove(path.join(proj, ...subdir, name))
209
+ const dest = path.join(proj, ...subdir, name)
210
+ if (subdir[1] === 'skills') {
211
+ await removeSkillEntry({ dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
212
+ } else {
213
+ await fs.remove(dest)
214
+ }
192
215
  logAction(`${subdir.join('/')}/${name}`, 'red', '×')
193
216
  count++
194
217
  }
@@ -214,7 +237,7 @@ async function copyCodexHooks(hooks, proj) {
214
237
  continue
215
238
  }
216
239
  const dest = path.join(dir, h.name)
217
- await fs.copy(h.sourcePath, dest, { overwrite: true, filter: copyFilter })
240
+ await fs.copy(h.sourcePath, dest, { overwrite: true, filter: copyFilter(h.sourcePath) })
218
241
  if (!await fs.pathExists(dest)) {
219
242
  logAction(`.codex/hooks/${h.name}`, 'yellow', '!')
220
243
  continue
@@ -327,8 +350,9 @@ export async function syncCodex({
327
350
  projectDir,
328
351
  global = false,
329
352
  selected,
353
+ homeDir,
354
+ sharedSkills,
330
355
  staleRemovals = {},
331
- preserveMissingRules = new Set(),
332
356
  } = {}) {
333
357
  const sourceDir = sourceDirOverride || await resolveSourceDir()
334
358
  if (!await fs.pathExists(sourceDir)) {
@@ -341,22 +365,17 @@ export async function syncCodex({
341
365
 
342
366
  const scans = {
343
367
  skills: await scanSkills(sourceDir),
344
- rules: await scanRules(sourceDir),
345
368
  hooks: await scanHooks(sourceDir),
346
369
  }
347
- console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.hooks.length} hooks\n`))
370
+ console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.hooks.length} hooks\n`))
348
371
 
349
- const agentsMdPath = global ? path.join(proj, '.codex', 'AGENTS.md') : path.join(proj, 'AGENTS.md')
350
- const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
351
372
  const existing = {
352
373
  skills: await scanExistingCodexSkills(proj),
353
- rules: parseManagedRuleNames(agentsMdContent),
354
374
  hooks: await scanExistingCodexHooks(proj),
355
375
  }
356
376
  const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
357
377
  const syncedSelection = {
358
378
  skills: scans.skills.filter(item => selectedNames('skills').has(item.name)),
359
- rules: scans.rules.filter(item => selectedNames('rules').has(item.name)),
360
379
  hooks: scans.hooks.filter(item => selectedNames('hooks').has(item.name)),
361
380
  }
362
381
  const selectedHookNames = new Set(syncedSelection.hooks.map(h => h.name))
@@ -366,11 +385,10 @@ export async function syncCodex({
366
385
  let syncedCount = 0
367
386
  let removedCount = 0
368
387
 
369
- syncedCount += await copySkillsFlat(syncedSelection.skills, proj)
370
- syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules, {
371
- preservedRuleNames: preserveMissingRules,
372
- targetPath: agentsMdPath,
373
- })
388
+ const skills = sharedSkills
389
+ ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
390
+ : await prepareSharedSkills({ skills: syncedSelection.skills, homeDir })
391
+ syncedCount += await copySkillsFlat(skills, proj)
374
392
  syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
375
393
  syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames, global)
376
394
 
@@ -386,7 +404,6 @@ export async function syncCodex({
386
404
  removedCount += await removeUnselectedFlat(['.codex', 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
387
405
 
388
406
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
389
- console.log(chalk.gray(` Rules → ${agentsMdPath}`))
390
407
  console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
391
408
  console.log(chalk.gray(` Hooks → ${path.join(proj, '.codex')}`))
392
409
  }