create-dwy 0.21.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 (32) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/index.js +6 -3
  3. package/package.json +1 -1
  4. package/src/cli-help.js +9 -2
  5. package/src/self-upgrade.js +7 -0
  6. package/src/skill-store.js +182 -0
  7. package/src/sync-all.js +134 -32
  8. package/src/sync-codex.js +15 -5
  9. package/src/sync-global-skills.js +65 -14
  10. package/src/sync-opencode.js +9 -5
  11. package/src/sync.js +13 -4
  12. package/templates/ai-tools/AGENTS.md +1 -1
  13. package/templates/ai-tools/CHANGELOG.md +6 -0
  14. package/templates/ai-tools/VERSION +1 -1
  15. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/progr/sql/createExternalTable.md +2 -2
  16. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tools/superset.md +1 -1
  17. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tutorials/Migrate_data_from_InfluxDB_to_DolphinDB.md +1 -1
  18. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +18 -25
  19. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +3 -3
  20. 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
  21. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +6 -70
  22. 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
  23. 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
  24. 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
  25. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +2 -2
  26. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +3 -3
  27. 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
  28. 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
  29. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +3 -7
  30. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/SKILL.md +58 -0
  31. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/delegation-guide.md +46 -0
  32. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/review-guide.md +57 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # create-dwy
2
2
 
3
+ ## [0.22.0] - 2026-11-02
4
+
5
+ ### Minor Changes
6
+
7
+ - Skills 改为用户目录 `.dwy/skills/<name>` 内的一份共享副本,项目和全局入口使用整个目录的软链接;同平台已有有效全局入口时清理项目重复入口。原真实目录先备份,自定义 Skills、Rules 和 Hooks 保留。
8
+ - 新增 `dwy migrate`,在老项目根目录一键迁移已有托管 Skills,无需重新勾选;重复执行不重复备份,已移除的历史模板和已有整个 Skills 根目录链接保留并报告。
9
+ - 新增 `dwy refresh`,使用当前 CLI 包内模板刷新所有已安装的共享副本;`dwy upgrade` 升级成功后自动调用新进程刷新,所有已迁移项目无需分别更新。
10
+ - 共享副本刷新加入互斥及整批失败恢复;扫描和清理统一保护目录链接目标,防止自链和穿透根链接误删文件。全局默认选择覆盖自定义 `CODEX_HOME`。
11
+ - CHANGELOG 规则明确要求每个版本条目包含版本号和日期,禁止 `Unreleased` 等占位标题,与是否发布无关;发布记录提取兼容带日期和方括号的版本标题。
12
+
13
+ ---
14
+
3
15
  ## 0.21.0
4
16
 
5
17
  ### Minor Changes
package/bin/index.js CHANGED
@@ -3,7 +3,8 @@
3
3
  import { readFileSync } from 'fs'
4
4
  import { fileURLToPath } from 'url'
5
5
  import { dirname, resolve } from 'path'
6
- import { runDwy } from '../src/sync-all.js'
6
+ import { runDwy, migrateSkills } from '../src/sync-all.js'
7
+ import { refreshSharedSkills } from '../src/skill-store.js'
7
8
  import { selfUpgrade } from '../src/self-upgrade.js'
8
9
  import { buildHelpText } from '../src/cli-help.js'
9
10
 
@@ -24,14 +25,16 @@ if (wantsHelp(argv)) {
24
25
  printHelp()
25
26
  } else if (argv.includes('--version') || argv.includes('-V')) {
26
27
  console.log(pkg.version)
27
- } else if (argv[0] === 'upgrade') {
28
+ } else if (['upgrade', 'refresh', 'migrate'].includes(argv[0])) {
28
29
  if (argv.length > 1) {
29
30
  console.error(`未知命令: dwy ${argv.join(' ')}`)
30
31
  printHelp()
31
32
  process.exitCode = 1
32
33
  } else {
33
34
  try {
34
- await selfUpgrade()
35
+ if (argv[0] === 'upgrade') await selfUpgrade()
36
+ if (argv[0] === 'refresh') await refreshSharedSkills()
37
+ if (argv[0] === 'migrate') await migrateSkills()
35
38
  } catch (error) {
36
39
  console.error(error.message)
37
40
  process.exitCode = 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "项目脚手架与多平台 AI 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli-help.js CHANGED
@@ -16,7 +16,9 @@ export function buildHelpText(version) {
16
16
  '用法:',
17
17
  ' dwy 全局更新通用基线,项目注入所选 Rules;Skills / Hooks 可选位置',
18
18
  ' dwy sync 同上',
19
- ' dwy upgrade 把 dwy 升级到 npm 最新正式版',
19
+ ' dwy upgrade 升级到 npm 最新正式版,并刷新共享 Skills',
20
+ ' dwy refresh 用当前 CLI 模板刷新已安装的共享 Skills',
21
+ ' dwy migrate 一键迁移老项目已有的托管 Skills,原目录先备份',
20
22
  ' dwy --help 显示本说明',
21
23
  ' dwy --version 显示版本',
22
24
  '',
@@ -37,6 +39,8 @@ export function buildHelpText(version) {
37
39
  ' 同步全局 Skills / Hooks:在项目目录执行 dwy,在位置步骤选择「全局」。',
38
40
  ' 更新或调整配置:再次执行 dwy sync,检查勾选项,再确认同步。',
39
41
  ' 更新 CLI:dwy upgrade(全局安装时可用;更新后执行 dwy --version 查看版本)。',
42
+ ' 迁移老项目:cd your-project 后执行 dwy migrate,无需重新勾选。',
43
+ ' 手动升级包后:执行一次 dwy refresh,所有已迁移的链接同时生效。',
40
44
  ' 首次安装:npm install -g create-dwy@latest',
41
45
  '',
42
46
  '平台支持:',
@@ -47,7 +51,10 @@ export function buildHelpText(version) {
47
51
  '',
48
52
  '同步仅处理所选平台;基线固定全局、Rules 固定项目,位置选择只影响 Skills / Hooks。',
49
53
  '取消已勾选的托管项会在确认后删除,保留手写内容。',
50
- '更新 CLI 只升级工具;本地配置需再执行 dwy sync 更新。',
54
+ 'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
55
+ '旧真实目录备份到同步根的 .dwy/skill-backups;个人 Skills、Rules、Hooks 不参与 migrate。',
56
+ '旧项目首次需执行 dwy migrate;迁移后无需逐项目刷新 Skills。',
57
+ 'Rules / Hooks 的模板更新仍需执行 dwy sync;已有会话缓存的 Skill 可能需要重新加载。',
51
58
  '',
52
59
  ].join('\n')
53
60
  }
@@ -141,6 +141,7 @@ export async function selfUpgrade({
141
141
  const latest = await fetchLatest()
142
142
  const cmp = compareVersions(current, latest)
143
143
  if (cmp === 0) {
144
+ await runCommand('dwy', ['refresh'])
144
145
  console.log(chalk.green(`已是最新正式版 ${current}`))
145
146
  return { status: 'up-to-date', current, latest, installer }
146
147
  }
@@ -153,6 +154,12 @@ export async function selfUpgrade({
153
154
  console.log(chalk.blue(`升级 ${PACKAGE_NAME} ${current} → ${latest}`))
154
155
  console.log(chalk.gray(` ${cmd} ${args.join(' ')}`))
155
156
  await runCommand(cmd, args)
157
+ try {
158
+ // 必须启动新安装的 CLI;pnpm 升级后旧进程的模块路径仍可能指向旧版本。
159
+ await runCommand('dwy', ['refresh'])
160
+ } catch (error) {
161
+ throw new Error(`CLI 已安装新版本,但共享 Skills 刷新失败:${error.message}`, { cause: error })
162
+ }
156
163
  console.log(chalk.green(`已升级到 ${latest}`))
157
164
  return { status: 'upgraded', current, latest, installer }
158
165
  }
@@ -0,0 +1,182 @@
1
+ import fs from 'fs-extra'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { copyFilter, resolveSourceDir, scanSkills } from './sync.js'
6
+ import { chalk } from './utils.js'
7
+
8
+ function validateName(name) {
9
+ if (!/^[\p{L}\p{N}_-]+$/u.test(name)) throw new Error(`无效 Skill 名称:${name}`)
10
+ }
11
+
12
+ async function readInstalledNames(homeDir) {
13
+ const file = path.join(homeDir, '.dwy', 'skill-store.json')
14
+ if (!await fs.pathExists(file)) return []
15
+ const names = await fs.readJson(file)
16
+ if (!Array.isArray(names) || names.some(name => typeof name !== 'string')) {
17
+ throw new Error(`共享 Skills 清单格式无效:${file}`)
18
+ }
19
+ names.forEach(validateName)
20
+ return names
21
+ }
22
+
23
+ /**
24
+ * 将包内 Skills 准备到稳定用户目录,返回指向共享副本的条目。
25
+ * 同一用户的刷新互斥;完整副本准备失败时不替换旧内容。外部 Skills 缓存不纳入本模块清单。
26
+ */
27
+ export async function prepareSharedSkills({ skills, homeDir = os.homedir() }) {
28
+ if (skills.length === 0) return []
29
+ for (const skill of skills) {
30
+ validateName(skill.name)
31
+ if (!(await fs.stat(path.join(skill.sourcePath, 'SKILL.md'))).isFile()) {
32
+ throw new Error(`Skill 缺少有效 SKILL.md:${skill.sourcePath}`)
33
+ }
34
+ }
35
+ const root = path.join(homeDir, '.dwy')
36
+ const store = path.join(root, 'skills')
37
+ await fs.ensureDir(store)
38
+ const lockPath = path.join(root, 'skill-store.lock')
39
+ let lock
40
+ try {
41
+ lock = await fs.open(lockPath, 'wx')
42
+ } catch (error) {
43
+ if (error.code === 'EEXIST') throw new Error(`另一个进程正在刷新 Skills;若前次异常退出,请移除 ${lockPath} 后重试`)
44
+ throw error
45
+ }
46
+ let staging
47
+ let preserveStaging = false
48
+ try {
49
+ staging = await fs.mkdtemp(path.join(root, '.skills-'))
50
+ const installed = new Set(await readInstalledNames(homeDir))
51
+ const prepared = []
52
+ const replacements = []
53
+ for (const skill of skills) {
54
+ const dest = path.join(store, skill.name)
55
+ const next = path.join(staging, skill.name)
56
+ if (path.resolve(skill.sourcePath) !== path.resolve(dest)) {
57
+ await fs.copy(skill.sourcePath, next, { dereference: true, filter: copyFilter(skill.sourcePath) })
58
+ replacements.push({ dest, next, old: path.join(staging, `${skill.name}.old`), saved: false, replaced: false })
59
+ }
60
+ installed.add(skill.name)
61
+ prepared.push({ ...skill, sourcePath: dest })
62
+ }
63
+ const manifest = path.join(staging, 'skill-store.json')
64
+ await fs.writeJson(manifest, [...installed].sort(), { spaces: 2 })
65
+ try {
66
+ for (const entry of replacements) {
67
+ const existing = await fs.lstat(entry.dest).catch(error => {
68
+ if (error.code === 'ENOENT') return null
69
+ throw error
70
+ })
71
+ if (existing) {
72
+ await fs.rename(entry.dest, entry.old)
73
+ entry.saved = true
74
+ }
75
+ await fs.rename(entry.next, entry.dest)
76
+ entry.replaced = true
77
+ }
78
+ await fs.rename(manifest, path.join(root, 'skill-store.json'))
79
+ } catch (error) {
80
+ const errors = [error]
81
+ for (const entry of [...replacements].reverse()) {
82
+ try {
83
+ if (entry.replaced) await fs.remove(entry.dest)
84
+ if (entry.saved) await fs.rename(entry.old, entry.dest)
85
+ } catch (restoreError) {
86
+ errors.push(restoreError)
87
+ }
88
+ }
89
+ if (errors.length > 1) {
90
+ preserveStaging = true
91
+ throw new AggregateError(errors, `刷新失败且自动恢复未完成,旧副本保留在 ${staging}`)
92
+ }
93
+ throw error
94
+ }
95
+ return prepared
96
+ } finally {
97
+ try {
98
+ if (staging && !preserveStaging) await fs.remove(staging)
99
+ } finally {
100
+ await fs.close(lock)
101
+ await fs.remove(lockPath)
102
+ }
103
+ }
104
+ }
105
+
106
+ async function entryParent(dest, backupRoot) {
107
+ const parent = await fs.realpath(path.dirname(dest))
108
+ const boundary = path.resolve(backupRoot, '..', '..')
109
+ const realBoundary = await fs.realpath(boundary)
110
+ const expected = path.join(realBoundary, path.relative(boundary, path.dirname(dest)))
111
+ if (parent !== expected) throw new Error(`Skills 根目录链接指向外部位置,保留原入口,不修改目标:${path.dirname(dest)}`)
112
+ return parent
113
+ }
114
+
115
+ /**
116
+ * 清理托管入口:真实目录移到备份根,软链接仅移除入口,不遍历其目标。
117
+ * 返回真实目录的备份路径;入口不存在或原为链接时返回 null。
118
+ */
119
+ export async function removeSkillEntry({ dest, backupRoot }) {
120
+ const stat = await fs.lstat(dest).catch(error => {
121
+ if (error.code === 'ENOENT') return null
122
+ throw error
123
+ })
124
+ if (!stat) return null
125
+ await entryParent(dest, backupRoot)
126
+ if (stat.isSymbolicLink()) {
127
+ await fs.unlink(dest)
128
+ return null
129
+ }
130
+ await fs.ensureDir(backupRoot)
131
+ const backup = path.join(backupRoot, `${path.basename(dest)}-${randomUUID()}`)
132
+ await fs.rename(dest, backup)
133
+ console.log(chalk.gray(`已备份 ${dest} → ${backup}`))
134
+ return backup
135
+ }
136
+
137
+ /** 将整个 Skill 目录链接到共享副本;原真实目录先备份,链接失败时恢复原入口。 */
138
+ export async function linkSkill({ skill, dest, backupRoot }) {
139
+ if (!(await fs.stat(path.join(skill.sourcePath, 'SKILL.md'))).isFile()) {
140
+ throw new Error(`Skill 缺少有效 SKILL.md:${skill.sourcePath}`)
141
+ }
142
+ await fs.ensureDir(path.dirname(dest))
143
+ const actualParent = await fs.realpath(path.dirname(dest))
144
+ // 整个全局 skills 根可能已链接到共享目录;此时入口就是源目录,不能把它再改为自链。
145
+ if (path.join(actualParent, path.basename(dest)) === await fs.realpath(skill.sourcePath)) return null
146
+ await entryParent(dest, backupRoot)
147
+ const stat = await fs.lstat(dest).catch(error => {
148
+ if (error.code === 'ENOENT') return null
149
+ throw error
150
+ })
151
+ const previousLink = stat?.isSymbolicLink() ? await fs.readlink(dest) : null
152
+ if (previousLink && path.resolve(path.dirname(dest), previousLink) === path.resolve(skill.sourcePath)) return null
153
+ const temporary = path.join(path.dirname(dest), `.${path.basename(dest)}.link-${randomUUID()}`)
154
+ await fs.symlink(path.resolve(skill.sourcePath), temporary, process.platform === 'win32' ? 'junction' : 'dir')
155
+ let backup
156
+ try {
157
+ backup = await removeSkillEntry({ dest, backupRoot })
158
+ try {
159
+ await fs.rename(temporary, dest)
160
+ } catch (error) {
161
+ if (backup) await fs.rename(backup, dest)
162
+ else if (previousLink) await fs.symlink(previousLink, dest, process.platform === 'win32' ? 'junction' : 'dir')
163
+ throw error
164
+ }
165
+ return backup
166
+ } finally {
167
+ await fs.remove(temporary)
168
+ }
169
+ }
170
+
171
+ /** 使用当前 CLI 包内模板刷新已有共享副本;新包移除的条目保留,避免其他项目坏链。 */
172
+ export async function refreshSharedSkills({ sourceDir, homeDir = os.homedir() } = {}) {
173
+ const names = await readInstalledNames(homeDir)
174
+ const scans = await scanSkills(sourceDir || await resolveSourceDir())
175
+ const available = new Map(scans.map(skill => [skill.name, skill]))
176
+ const updated = names.filter(name => available.has(name))
177
+ const retained = names.filter(name => !available.has(name))
178
+ await prepareSharedSkills({ skills: updated.map(name => available.get(name)), homeDir })
179
+ for (const name of retained) console.log(chalk.yellow(`当前 CLI 已移除 ${name},保留共享旧副本以免其他项目链接失效。`))
180
+ console.log(chalk.green(`已刷新 ${updated.length} 个共享 Skills,保留 ${retained.length} 个旧副本。`))
181
+ return { updated, retained }
182
+ }
package/src/sync-all.js CHANGED
@@ -8,7 +8,12 @@ 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,
@@ -117,7 +122,7 @@ export function buildSelectionDefaultsFromSyncState(existing, syncState) {
117
122
  return hasSetItems(stateDefaults) ? stateDefaults : existing
118
123
  }
119
124
 
120
- async function scanCombinedExisting(projectDir) {
125
+ async function scanCombinedExisting(projectDir, global = false) {
121
126
  const claudeDir = path.join(projectDir, '.claude')
122
127
  const agentsMdPath = path.join(projectDir, 'AGENTS.md')
123
128
  const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
@@ -126,6 +131,7 @@ async function scanCombinedExisting(projectDir) {
126
131
  skills: unionSets(
127
132
  await scanExisting(claudeDir, 'skills'),
128
133
  await scanExistingCodexSkills(projectDir),
134
+ await scanExisting(path.join(projectDir, ...(global ? ['.config', 'opencode'] : ['.opencode'])), 'skills'),
129
135
  ),
130
136
  rules: unionSets(
131
137
  await scanExisting(claudeDir, 'rules'),
@@ -174,6 +180,9 @@ async function syncSkillsOnly({
174
180
  templateSkillNames,
175
181
  staleSkillNames,
176
182
  skillScope,
183
+ homeDir,
184
+ global,
185
+ duplicateNames,
177
186
  }) {
178
187
  const writeProject = skillScope.destinations.includes('project')
179
188
  if (!writeProject) return
@@ -185,24 +194,25 @@ async function syncSkillsOnly({
185
194
  }
186
195
 
187
196
  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
197
  for (const platform of skillPlatforms) {
199
- 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)
200
209
  const existingNames = await scanExisting(path.dirname(destRoot), 'skills')
201
210
  await removeManagedItems(
202
211
  destRoot,
203
212
  existingNames,
204
213
  toRemove,
205
214
  path.relative(projectDir, destRoot),
215
+ path.join(global ? homeDir : projectDir, '.dwy', 'skill-backups'),
206
216
  )
207
217
  }
208
218
  }
@@ -381,12 +391,12 @@ function buildNextPlatformState(selected, staleEntries, approvedRemovals, platfo
381
391
  return state
382
392
  }
383
393
 
384
- async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix) {
394
+ async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix, backupRoot) {
385
395
  let removedCount = 0
386
396
 
387
397
  for (const name of existingNames) {
388
398
  if (!managedNames.has(name)) continue
389
- await fs.remove(path.join(targetDir, name))
399
+ await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot })
390
400
  logAction(`${labelPrefix}/${name}`, 'red', '×')
391
401
  removedCount++
392
402
  }
@@ -419,6 +429,62 @@ export async function runDwy(opts = {}) {
419
429
  await syncAll(opts)
420
430
  }
421
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
+
422
488
  export async function syncAll({
423
489
  sourceDir: sourceDirOverride,
424
490
  projectDir: projectDirOverride,
@@ -452,7 +518,10 @@ export async function syncAll({
452
518
  const syncState = await loadSyncState(projectDir)
453
519
  const globalState = await loadSyncState(homeDir)
454
520
  const projectDefaults = buildSelectionDefaultsFromSyncState(existing, syncState)
455
- 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)
456
525
  const selectionDefaults = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, unionSets(projectDefaults[type], globalDefaults[type])]))
457
526
  selectionDefaults.rules = projectDefaults.rules
458
527
  const syncMode = normalizeSyncMode(syncModeOverride)
@@ -529,6 +598,7 @@ export async function syncAll({
529
598
  for (const dest of legacyClaudeRules) console.log(`删除旧 Claude Rules → ${dest}`)
530
599
  }
531
600
  console.log('Hooks 按支持的平台自动启用。')
601
+ console.log('Skills 将迁为用户共享副本的目录链接;原真实目录先备份,同平台全局入口优先。')
532
602
  for (const platform of selectedPlatforms) {
533
603
  for (const type of PLATFORM_SYNC_TYPES[platform]) {
534
604
  const existingNames = currentManaged[platform]?.[type] || new Set()
@@ -548,7 +618,32 @@ export async function syncAll({
548
618
  if (!selectedOverride && selected.skills.some(item => item.category === '产品0到1')) await ensureSkillsInstalled()
549
619
 
550
620
  const selectedSkillNames = new Set((selected.skills || []).map(item => item.name))
551
- 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
+ })
552
647
  let syncedAnySupportedPlatform = false
553
648
 
554
649
  if (syncMode === SYNC_MODE_SKILLS) {
@@ -563,6 +658,9 @@ export async function syncAll({
563
658
  templateSkillNames: new Set(scans.skills.map(item => item.name)),
564
659
  staleSkillNames,
565
660
  skillScope,
661
+ homeDir,
662
+ global,
663
+ duplicateNames,
566
664
  })
567
665
  syncedAnySupportedPlatform = selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))
568
666
  || skillScope.destinations.some(id => id !== 'project')
@@ -586,7 +684,9 @@ export async function syncAll({
586
684
  sourceDir,
587
685
  projectDir: targetRoot,
588
686
  global,
589
- selected,
687
+ selected: selectionFor('claude'),
688
+ homeDir,
689
+ sharedSkills,
590
690
  staleRemovals: approvedStaleRemovals.claude,
591
691
  })
592
692
  }
@@ -598,7 +698,9 @@ export async function syncAll({
598
698
  sourceDir,
599
699
  projectDir: targetRoot,
600
700
  global,
601
- selected,
701
+ selected: selectionFor('codex'),
702
+ homeDir,
703
+ sharedSkills,
602
704
  staleRemovals: approvedStaleRemovals.codex,
603
705
  })
604
706
  }
@@ -619,7 +721,9 @@ export async function syncAll({
619
721
  sourceDir,
620
722
  projectDir: targetRoot,
621
723
  global,
622
- selected,
724
+ selected: selectionFor('opencode'),
725
+ homeDir,
726
+ sharedSkills,
623
727
  staleRemovals: approvedStaleRemovals.opencode,
624
728
  })
625
729
  }
@@ -627,21 +731,19 @@ export async function syncAll({
627
731
 
628
732
  }
629
733
 
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
- })
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
+ }
639
741
  }
640
742
 
641
743
  if (syncMode === SYNC_MODE_SKILLS) {
642
744
  for (const platform of selectedPlatforms) {
643
- const previous = syncState.platforms[platform] || createEmptyPlatformState()
644
- 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: [] }
645
747
  targetState.platforms[platform] = {
646
748
  ...previous,
647
749
  skills: buildNextPlatformState(skillsOnlySelected, staleEntries, approvedStaleRemovals, platform).skills,
@@ -649,7 +751,7 @@ export async function syncAll({
649
751
  }
650
752
  } else {
651
753
  for (const platform of selectedPlatforms) {
652
- const next = buildNextPlatformState(selected, staleEntries, approvedStaleRemovals, platform)
754
+ const next = buildNextPlatformState(global ? selected : selectionFor(platform), staleEntries, approvedStaleRemovals, platform)
653
755
  if (global) {
654
756
  syncState.platforms[platform] = {
655
757
  ...(syncState.platforms[platform] || createEmptyPlatformState()),
@@ -675,7 +777,7 @@ export async function syncAll({
675
777
  }
676
778
  targetState.skillScope = skillScope
677
779
  targetState.syncMode = syncMode
678
- if (syncMode === SYNC_MODE_ALL && global) {
780
+ if (global) {
679
781
  syncState.syncMode = syncMode
680
782
  await saveSyncState(projectDir, syncState)
681
783
  if (cliPlatforms.length) await saveSyncState(homeDir, targetState)
package/src/sync-codex.js CHANGED
@@ -1,6 +1,7 @@
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,
@@ -186,15 +187,14 @@ export async function scanExistingCodexSkills(proj) {
186
187
  const dir = path.join(proj, '.agents', 'skills')
187
188
  if (!await fs.pathExists(dir)) return new Set()
188
189
  const entries = await fs.readdir(dir, { withFileTypes: true })
189
- 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))
190
191
  }
191
192
 
192
193
  async function copySkillsFlat(skills, proj) {
193
194
  const root = path.join(proj, '.agents', 'skills')
194
195
  for (const s of skills) {
195
196
  const dest = path.join(root, s.name)
196
- await fs.ensureDir(path.dirname(dest))
197
- await fs.copy(s.sourcePath, dest, { overwrite: true, filter: copyFilter(s.sourcePath) })
197
+ await linkSkill({ skill: s, dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
198
198
  logAction(`.agents/skills/${s.name}`)
199
199
  }
200
200
  return skills.length
@@ -206,7 +206,12 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
206
206
  if (selectedNames.has(name)) continue
207
207
  // 项目独有(模板未提供过)的条目永不删除,保留用户自定义
208
208
  if (!templateNames.has(name)) continue
209
- 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
+ }
210
215
  logAction(`${subdir.join('/')}/${name}`, 'red', '×')
211
216
  count++
212
217
  }
@@ -345,6 +350,8 @@ export async function syncCodex({
345
350
  projectDir,
346
351
  global = false,
347
352
  selected,
353
+ homeDir,
354
+ sharedSkills,
348
355
  staleRemovals = {},
349
356
  } = {}) {
350
357
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -378,7 +385,10 @@ export async function syncCodex({
378
385
  let syncedCount = 0
379
386
  let removedCount = 0
380
387
 
381
- syncedCount += await copySkillsFlat(syncedSelection.skills, proj)
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)
382
392
  syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
383
393
  syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames, global)
384
394