create-dwy 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/index.js +6 -3
  3. package/package.json +1 -1
  4. package/src/cli-help.js +29 -13
  5. package/src/self-upgrade.js +7 -0
  6. package/src/skill-store.js +197 -0
  7. package/src/skills-install.js +47 -11
  8. package/src/sync-all.js +216 -605
  9. package/src/sync-codex.js +48 -25
  10. package/src/sync-global-skills.js +64 -14
  11. package/src/sync-opencode.js +9 -5
  12. package/src/sync-plan.js +278 -0
  13. package/src/sync.js +33 -18
  14. package/src/tab-multiselect.js +7 -5
  15. package/templates/ai-tools/AGENTS.md +1 -1
  16. package/templates/ai-tools/CHANGELOG.md +6 -0
  17. package/templates/ai-tools/VERSION +1 -1
  18. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/progr/sql/createExternalTable.md +2 -2
  19. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tools/superset.md +1 -1
  20. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tutorials/Migrate_data_from_InfluxDB_to_DolphinDB.md +1 -1
  21. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +18 -25
  22. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +3 -3
  23. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/backend-deploy.md +4 -4
  24. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +6 -70
  25. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/frontend-deploy.md +3 -3
  26. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/generic-release.md +5 -5
  27. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/git-tag.md +4 -79
  28. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +2 -2
  29. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +3 -3
  30. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/publish-config.md +4 -4
  31. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/version-sync.md +7 -7
  32. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +3 -7
  33. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/SKILL.md +58 -0
  34. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/delegation-guide.md +46 -0
  35. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/review-guide.md +57 -0
package/src/sync-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, ensureSharedSkills, 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
  }
@@ -215,16 +220,16 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
215
220
 
216
221
  // ---------- hooks → .codex/ ----------
217
222
 
218
- export async function scanExistingCodexHooks(proj) {
219
- const dir = path.join(proj, '.codex', 'hooks')
223
+ export async function scanExistingCodexHooks(proj, configDir = path.join(proj, '.codex')) {
224
+ const dir = path.join(configDir, 'hooks')
220
225
  if (!await fs.pathExists(dir)) return new Set()
221
226
  const entries = await fs.readdir(dir)
222
227
  return new Set(entries.filter(n => n !== '.gitkeep' && n !== '.DS_Store'))
223
228
  }
224
229
 
225
- async function copyCodexHooks(hooks, proj) {
230
+ async function copyCodexHooks(hooks, configDir) {
226
231
  if (hooks.length === 0) return 0
227
- const dir = path.join(proj, '.codex', 'hooks')
232
+ const dir = path.join(configDir, 'hooks')
228
233
  await fs.ensureDir(dir)
229
234
  for (const h of hooks) {
230
235
  if (!await fs.pathExists(h.sourcePath)) {
@@ -243,7 +248,12 @@ async function copyCodexHooks(hooks, proj) {
243
248
  return hooks.length
244
249
  }
245
250
 
246
- function buildCodexHooksJson(manifests, selectedHookNames, global) {
251
+ function globalHookCommand(configDir, name) {
252
+ const script = path.join(configDir, 'hooks', name).replace(/\\/g, '/')
253
+ return `bash '${script.replace(/'/g, "'\\''")}'`
254
+ }
255
+
256
+ function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
247
257
  const hooksJson = { hooks: {} }
248
258
  const grouped = new Map()
249
259
 
@@ -258,7 +268,7 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
258
268
  hooks.push({
259
269
  type: 'command',
260
270
  command: global
261
- ? `bash "$HOME/.codex/hooks/${manifest.name}"`
271
+ ? globalHookCommand(configDir, manifest.name)
262
272
  : `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
263
273
  ...(manifest.timeout ? { timeout: manifest.timeout } : {}),
264
274
  })
@@ -274,13 +284,14 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
274
284
  return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
275
285
  }
276
286
 
277
- function isManagedCodexHookCommand(command, managedHookNames) {
287
+ function isManagedCodexHookCommand(command, managedHookNames, configDir) {
278
288
  if (typeof command !== 'string') return false
279
289
  const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
280
290
  return !!(match && managedHookNames.has(match[1]))
291
+ || [...managedHookNames].some(name => command === globalHookCommand(configDir, name))
281
292
  }
282
293
 
283
- function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
294
+ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
284
295
  const merged = {}
285
296
  const sourceEvents = new Set([
286
297
  ...Object.keys(existingHooks || {}),
@@ -291,7 +302,7 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
291
302
  const kept = []
292
303
 
293
304
  for (const cfg of existingHooks?.[event] || []) {
294
- const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames))
305
+ const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames, configDir))
295
306
  if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
296
307
  }
297
308
 
@@ -302,10 +313,10 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
302
313
  return Object.keys(merged).length > 0 ? merged : undefined
303
314
  }
304
315
 
305
- async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
306
- const dest = path.join(proj, '.codex', 'hooks.json')
316
+ async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global) {
317
+ const dest = path.join(configDir, 'hooks.json')
307
318
  const manifests = await loadHookManifests(sourceDir)
308
- const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
319
+ const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
309
320
  const managedHookNames = new Set(
310
321
  manifests
311
322
  .filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
@@ -315,7 +326,7 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
315
326
  let nextJson
316
327
  if (await fs.pathExists(dest)) {
317
328
  const existing = await fs.readJson(dest)
318
- const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames)
329
+ const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames, configDir)
319
330
  if (!nextHooks) {
320
331
  if (!existing.hooks) return 0
321
332
  const { hooks, ...rest } = existing
@@ -339,12 +350,16 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
339
350
  return 1
340
351
  }
341
352
 
342
- /** 同步 Codex 配置;global 模式将规则写入用户 .codex/AGENTS.md。 */
353
+ /** 同步 Codex Skills 与 Hooks;全局 Hooks 遵循 codexHome。 */
343
354
  export async function syncCodex({
344
355
  sourceDir: sourceDirOverride,
345
356
  projectDir,
346
357
  global = false,
347
358
  selected,
359
+ homeDir,
360
+ codexHome,
361
+ sharedSkills,
362
+ syncTypes = ['skills', 'hooks'],
348
363
  staleRemovals = {},
349
364
  } = {}) {
350
365
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -353,6 +368,7 @@ export async function syncCodex({
353
368
  }
354
369
 
355
370
  const proj = projectDir || process.cwd()
371
+ const configDir = global && codexHome ? codexHome : path.join(proj, '.codex')
356
372
 
357
373
  console.log(chalk.blue('\nScanning available config for Codex...\n'))
358
374
 
@@ -364,7 +380,7 @@ export async function syncCodex({
364
380
 
365
381
  const existing = {
366
382
  skills: await scanExistingCodexSkills(proj),
367
- hooks: await scanExistingCodexHooks(proj),
383
+ hooks: await scanExistingCodexHooks(proj, configDir),
368
384
  }
369
385
  const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
370
386
  const syncedSelection = {
@@ -378,9 +394,16 @@ export async function syncCodex({
378
394
  let syncedCount = 0
379
395
  let removedCount = 0
380
396
 
381
- syncedCount += await copySkillsFlat(syncedSelection.skills, proj)
382
- syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
383
- syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames, global)
397
+ if (syncTypes.includes('skills')) {
398
+ const skills = sharedSkills
399
+ ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
400
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
401
+ syncedCount += await copySkillsFlat(skills, proj)
402
+ }
403
+ if (syncTypes.includes('hooks')) {
404
+ syncedCount += await copyCodexHooks(syncedSelection.hooks, configDir)
405
+ syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global)
406
+ }
384
407
 
385
408
  const skillTemplateNames = new Set([
386
409
  ...scans.skills.map(s => s.name),
@@ -390,10 +413,10 @@ export async function syncCodex({
390
413
  ...scans.hooks.map(h => h.name),
391
414
  ...(staleRemovals.hooks || new Set()),
392
415
  ])
393
- removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
394
- removedCount += await removeUnselectedFlat(['.codex', 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
416
+ if (syncTypes.includes('skills')) removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
417
+ if (syncTypes.includes('hooks')) removedCount += await removeUnselectedFlat([path.relative(proj, configDir), 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
395
418
 
396
419
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
397
420
  console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
398
- console.log(chalk.gray(` Hooks → ${path.join(proj, '.codex')}`))
421
+ console.log(chalk.gray(` Hooks → ${configDir}`))
399
422
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
- * 将已选 skill 额外拷到用户全局目录。
3
- * 项目目录同步仍由各平台 sync 负责;本模块只处理 ~/.claude / ~/.grok 等 home 下目录。
4
- * 只覆盖本次点名的 skill,不删除全局目录里其它个人 skill。
2
+ * 将 Skills 安装为用户共享副本的目录链接,并处理同平台全局优先去重。
3
+ * 只处理模板或同步记录中的条目;个人 Skill 不参与清理,原真实目录先备份。
5
4
  */
6
5
 
7
6
  import fs from 'fs-extra'
8
7
  import os from 'node:os'
9
8
  import path from 'path'
10
- import { copyFilter, logAction } from './sync.js'
9
+ import { logAction, scanExisting } from './sync.js'
10
+ import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
11
11
 
12
12
  /** 项目目录:随所选平台落到 .claude/skills、.agents/skills 等 */
13
13
  export const SKILL_DEST_PROJECT = 'project'
@@ -115,7 +115,7 @@ export function listSkillDestOptions(homeDir = os.homedir()) {
115
115
  }
116
116
 
117
117
  /**
118
- * 把 skill 目录拷到已选全局 dest。destIds 含 project 时跳过(项目侧已同步)。
118
+ * 将 skill 的共享副本链接到已选全局 dest。destIds 含 project 时跳过。
119
119
  * 只写传入的 skills,不清理目标根目录里其它条目。
120
120
  *
121
121
  * @param {{ skills: Array<{ name: string, sourcePath: string }>, destIds: string[], homeDir?: string }} opts
@@ -123,15 +123,15 @@ export function listSkillDestOptions(homeDir = os.homedir()) {
123
123
  */
124
124
  export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.homedir() }) {
125
125
  let count = 0
126
+ const sharedSkills = await ensureSharedSkills({ skills, homeDir })
126
127
 
127
128
  for (const destId of destIds) {
128
129
  const destRoot = resolveGlobalSkillDir(destId, homeDir)
129
130
  if (!destRoot) continue
130
131
 
131
- for (const skill of skills) {
132
+ for (const skill of sharedSkills) {
132
133
  const dest = path.join(destRoot, skill.name)
133
- await fs.ensureDir(path.dirname(dest))
134
- await fs.copy(skill.sourcePath, dest, { overwrite: true, filter: copyFilter(skill.sourcePath) })
134
+ await linkSkill({ skill, dest, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') })
135
135
  logAction(`${DEST_LABELS[destId] || destId}/${skill.name}`)
136
136
  count++
137
137
  }
@@ -141,23 +141,23 @@ export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.hom
141
141
  }
142
142
 
143
143
  /**
144
- * 仅 Skills 模式:把 skill 拷到项目内已选平台的 skills 目录。
144
+ * 将 skill 的共享副本链接到项目内已选平台的 skills 目录。
145
145
  * 不写 rules / commands / hooks。
146
146
  *
147
- * @param {{ projectDir: string, platforms: string[], skills: Array<{ name: string, sourcePath: string }> }} opts
147
+ * @param {{ projectDir: string, platforms: string[], skills: Array<{ name: string, sourcePath: string }>, homeDir?: string }} opts
148
148
  * @returns {Promise<number>}
149
149
  */
150
- export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills }) {
150
+ export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills, homeDir }) {
151
151
  let count = 0
152
+ const sharedSkills = await ensureSharedSkills({ skills, homeDir })
152
153
 
153
154
  for (const platform of platforms) {
154
155
  const destRoot = resolveProjectSkillDir(projectDir, platform)
155
156
  if (!destRoot) continue
156
157
 
157
- for (const skill of skills) {
158
+ for (const skill of sharedSkills) {
158
159
  const dest = path.join(destRoot, skill.name)
159
- await fs.ensureDir(path.dirname(dest))
160
- await fs.copy(skill.sourcePath, dest, { overwrite: true, filter: copyFilter(skill.sourcePath) })
160
+ await linkSkill({ skill, dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
161
161
  logAction(`${path.relative(projectDir, destRoot)}/${skill.name}`)
162
162
  count++
163
163
  }
@@ -165,3 +165,53 @@ export async function copySkillsToProjectPlatforms({ projectDir, platforms, skil
165
165
 
166
166
  return count
167
167
  }
168
+
169
+ /** 当前平台实际发现的用户级目录;OpenCode 同时兼容 Claude 与 .agents。 */
170
+ export function globalSkillRoots(platform, homeDir, codexHome = path.join(homeDir, '.codex')) {
171
+ if (platform === 'claude') return [resolveGlobalSkillDir('claude', homeDir)]
172
+ if (platform === 'codex') return [resolveGlobalSkillDir('agents', homeDir), path.join(codexHome, 'skills')]
173
+ if (platform === 'opencode') return ['opencode', 'claude', 'agents'].map(id => resolveGlobalSkillDir(id, homeDir))
174
+ return []
175
+ }
176
+
177
+ /** 查找有效全局入口;坏链或目录缺少 SKILL.md 时不能作为项目去重依据。 */
178
+ export async function findGlobalSkill({ name, platform, homeDir, codexHome }) {
179
+ for (const root of globalSkillRoots(platform, homeDir, codexHome)) {
180
+ const dest = path.join(root, name)
181
+ const stat = await fs.stat(path.join(dest, 'SKILL.md')).catch(error => {
182
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
183
+ throw error
184
+ })
185
+ if (stat?.isFile()) return dest
186
+ }
187
+ return null
188
+ }
189
+
190
+ /** 按实际发现范围扫描有效全局 Skills,供默认选择使用;覆盖自定义 CODEX_HOME。 */
191
+ export async function scanGlobalSkills({ homeDir, codexHome }) {
192
+ const names = new Set()
193
+ for (const platform of ['claude', 'codex', 'opencode']) {
194
+ for (const root of globalSkillRoots(platform, homeDir, codexHome)) {
195
+ for (const name of await scanExisting(path.dirname(root), 'skills')) {
196
+ if (await findGlobalSkill({ name, platform, homeDir, codexHome })) names.add(name)
197
+ }
198
+ }
199
+ }
200
+ return names
201
+ }
202
+
203
+ /** 全局优先:仅备份并移除项目重复入口,不改写全局提供者,返回去重名称。 */
204
+ export async function deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome }) {
205
+ const projectRoot = resolveProjectSkillDir(projectDir, platform)
206
+ const duplicates = new Set()
207
+ if (!projectRoot || path.resolve(projectDir) === path.resolve(homeDir)) return duplicates
208
+ for (const skill of skills) {
209
+ const globalEntry = await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })
210
+ if (!globalEntry) continue
211
+ const dest = path.join(projectRoot, skill.name)
212
+ await removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
213
+ duplicates.add(skill.name)
214
+ logAction(`${platform}/${skill.name} 使用全局入口,移除项目重复入口`)
215
+ }
216
+ return duplicates
217
+ }
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { chalk } from './utils.js'
4
+ import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
4
5
  import {
5
- copyFilter,
6
6
  logAction,
7
7
  resolveSourceDir,
8
8
  scanExisting,
@@ -15,8 +15,7 @@ async function copyItemsTo(baseDir, typeDir, items) {
15
15
 
16
16
  for (const item of items) {
17
17
  const dest = path.join(targetDir, item.name)
18
- await fs.ensureDir(path.dirname(dest))
19
- await fs.copy(item.sourcePath, dest, { overwrite: true, filter: copyFilter(item.sourcePath) })
18
+ await linkSkill({ skill: item, dest, backupRoot: path.join(baseDir, '..', '.dwy', 'skill-backups') })
20
19
  logAction(`.opencode/${typeDir}/${item.name}`)
21
20
  count++
22
21
  }
@@ -31,7 +30,7 @@ async function removeUnselected(typeDir, existingNames, selectedNames, templateN
31
30
  for (const name of existingNames) {
32
31
  if (selectedNames.has(name)) continue
33
32
  if (!templateNames.has(name)) continue
34
- await fs.remove(path.join(targetDir, name))
33
+ await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot: path.join(baseDir, '..', '.dwy', 'skill-backups') })
35
34
  logAction(`.opencode/${typeDir}/${name}`, 'red', '×')
36
35
  count++
37
36
  }
@@ -45,6 +44,8 @@ export async function syncOpenCode({
45
44
  projectDir,
46
45
  global = false,
47
46
  selected,
47
+ homeDir,
48
+ sharedSkills,
48
49
  staleRemovals = {},
49
50
  } = {}) {
50
51
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -74,7 +75,10 @@ export async function syncOpenCode({
74
75
  let syncedCount = 0
75
76
  let removedCount = 0
76
77
 
77
- syncedCount += await copyItemsTo(openCodeDir, 'skills', syncedSelection.skills)
78
+ const skills = sharedSkills
79
+ ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
80
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
81
+ syncedCount += await copyItemsTo(openCodeDir, 'skills', skills)
78
82
 
79
83
  removedCount += await removeUnselected(
80
84
  'skills',
@@ -0,0 +1,278 @@
1
+ import fs from 'fs-extra'
2
+ import path from 'node:path'
3
+ import { ensureSharedSkills, linkSkill, removeSkillEntry } from './skill-store.js'
4
+ import { findGlobalSkill, globalSkillRoots, resolveGlobalSkillDir, resolveProjectSkillDir } from './sync-global-skills.js'
5
+ import { loadHookManifests, syncClaude } from './sync.js'
6
+ import { removeLegacyBaseline, syncCodex, syncGlobalBaselineDoc, syncProjectAgentsMd } from './sync-codex.js'
7
+ import { syncCursor } from './sync-cursor.js'
8
+ import { planMissingExternalSkills } from './skills-install.js'
9
+
10
+ /** 平台显示名,交互和变更清单共用。 */
11
+ export const PLATFORM_LABELS = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', cursor: 'Cursor' }
12
+ /** 项目范围的平台能力;全局范围用通用规范替代项目 Rules,且不支持 Cursor。 */
13
+ export const PLATFORM_SYNC_TYPES = {
14
+ claude: ['skills', 'rules', 'hooks'], codex: ['skills', 'rules', 'hooks'],
15
+ opencode: ['skills', 'rules'], cursor: ['rules'],
16
+ }
17
+
18
+ /** 只提供所选范围和平台支持的内容;未勾选类型不进入清理或状态更新。 */
19
+ export function contentOptions(scope, platforms) {
20
+ const supports = type => platforms.some(platform => PLATFORM_SYNC_TYPES[platform]?.includes(type))
21
+ return [
22
+ ...(supports('skills') ? [{ value: 'skills', label: 'Skills', description: '管理当前范围的启用入口;已有共享内容不刷新' }] : []),
23
+ ...(scope === 'project' && supports('rules') ? [{ value: 'rules', label: '项目规则(Rules)', description: '只更新当前项目的托管规则' }] : []),
24
+ ...(scope === 'global' ? [{ value: 'baseline', label: '全局通用规范', description: '更新所选工具的原生全局指令入口,不修改项目规则' }] : []),
25
+ ...(supports('hooks') ? [{ value: 'hooks', label: 'Hooks(按平台自动同步)', description: '仅选择此类型后,自动同步所选平台支持的 Hooks' }] : []),
26
+ ]
27
+ }
28
+
29
+ /** 为项目 Skills 标注平台覆盖;只有全部适用平台已全局提供时才禁止重复选择。 */
30
+ export async function projectSkillChoices(skills, platforms, { homeDir, codexHome }) {
31
+ const applicable = platforms.filter(platform => PLATFORM_SYNC_TYPES[platform]?.includes('skills'))
32
+ return Promise.all(skills.map(async skill => {
33
+ const covered = []
34
+ for (const platform of applicable) {
35
+ if (await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) covered.push(platform)
36
+ }
37
+ const disabled = applicable.length > 0 && covered.length === applicable.length
38
+ const missing = applicable.filter(platform => !covered.includes(platform))
39
+ const status = disabled ? '全局已启用' : covered.length ? `${missing.map(p => PLATFORM_LABELS[p]).join('、')} 可安装到项目` : ''
40
+ return { ...skill, disabled, label: status ? `${skill.name}(${status})` : skill.name,
41
+ description: [covered.length ? `全局已提供:${covered.map(p => PLATFORM_LABELS[p]).join('、')}` : '', skill.description].filter(Boolean).join(';') }
42
+ }))
43
+ }
44
+
45
+ const safeName = name => typeof name === 'string' && name.length > 0 && name !== '.' && name !== '..' && path.basename(name) === name && !/[\\/]/.test(name)
46
+ const cursorRuleName = name => `dwy-${name.replace(/\.md$/i, '')}.mdc`
47
+
48
+ async function existsEntry(dest) {
49
+ return fs.lstat(dest).then(() => true).catch(error => {
50
+ if (error.code === 'ENOENT') return false
51
+ throw error
52
+ })
53
+ }
54
+
55
+ async function validSkill(dest) {
56
+ const stat = await fs.stat(path.join(dest, 'SKILL.md')).catch(error => {
57
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
58
+ throw error
59
+ })
60
+ return Boolean(stat?.isFile())
61
+ }
62
+
63
+ function ownedGlobalRoots(platform, homeDir, codexHome) {
64
+ if (platform === 'codex') return globalSkillRoots(platform, homeDir, codexHome)
65
+ return [resolveGlobalSkillDir(platform, homeDir)]
66
+ }
67
+
68
+ /**
69
+ * 只读计算本次全部操作和下一份状态;run 回调只在确认后执行。
70
+ * Skills 的入口与内容刷新分离;项目去重只删除项目入口,不改写全局提供者。
71
+ */
72
+ export async function buildSyncPlan({
73
+ scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
74
+ projectState, globalState, staleRemovals = {}, staleEntries = {}, baseline,
75
+ skillScope = { destinations: ['project'], globalSkills: [] },
76
+ }) {
77
+ const global = scope === 'global'
78
+ const isHome = path.resolve(projectDir) === path.resolve(homeDir)
79
+ || (await fs.pathExists(projectDir) && await fs.pathExists(homeDir)
80
+ && await fs.realpath(projectDir) === await fs.realpath(homeDir))
81
+ if (!global && isHome) {
82
+ throw new Error('当前目录是用户主目录,请选择全局范围或进入项目目录')
83
+ }
84
+ const allowed = new Set(contentOptions(scope, platforms).map(option => option.value))
85
+ if (platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))
86
+ || contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
87
+ const active = new Set(contentTypes)
88
+ if (active.has('baseline') && !baseline.trim()) throw new Error('没有可同步的全局通用规范')
89
+ const operations = []
90
+ const nextProject = structuredClone(projectState), nextGlobal = structuredClone(globalState)
91
+ const targetState = global ? nextGlobal : nextProject
92
+ const targetRoot = global ? homeDir : projectDir
93
+ const selectedSkillNames = new Set(selected.skills.map(item => item.name))
94
+ const skillSources = new Map(scans.skills.map(item => [item.name, item]))
95
+ const neededSkills = new Map()
96
+ const addedGlobalPaths = new Set(), removedGlobalPaths = new Set()
97
+ const localCleanup = new Set()
98
+ let projectChanged = !global && contentTypes.length > 0
99
+ const add = (kind, label, paths, run) => operations.push({ kind, label, paths, run })
100
+ const platformState = platform => targetState.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
101
+ const approved = (platform, type) => [...(staleRemovals[platform]?.[type] || [])].filter(safeName)
102
+ const retained = (platform, type) => (staleEntries[platform]?.[type] || []).filter(name => safeName(name) && !approved(platform, type).includes(name))
103
+ const backupRoot = path.join(targetRoot, '.dwy', 'skill-backups')
104
+ const providedToProject = async (name, platform) => globalSkillRoots(platform, homeDir, codexHome)
105
+ .some(root => addedGlobalPaths.has(path.join(root, name))) || Boolean(await findGlobalSkill({ name, platform, homeDir, codexHome }))
106
+
107
+ if (active.has('skills')) {
108
+ for (const destination of skillScope.destinations.filter(id => id !== 'project')) {
109
+ const root = resolveGlobalSkillDir(destination, homeDir)
110
+ if (!root) continue
111
+ for (const name of skillScope.globalSkills) {
112
+ const skill = skillSources.get(name)
113
+ if (!skill) continue
114
+ neededSkills.set(name, skill)
115
+ const dest = path.join(root, name), shared = { ...skill, sourcePath: path.join(homeDir, '.dwy', 'skills', name) }
116
+ add('link-skill', `显式脚本配置的额外全局入口:${name}`, [dest],
117
+ () => linkSkill({ skill: shared, dest, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') }))
118
+ addedGlobalPaths.add(dest)
119
+ }
120
+ }
121
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('skills'))) {
122
+ if (!global && !skillScope.destinations.includes('project')) continue
123
+ const roots = global ? ownedGlobalRoots(platform, homeDir, codexHome) : [resolveProjectSkillDir(projectDir, platform)]
124
+ const managed = new Set([...skillSources.keys(), ...approved(platform, 'skills')])
125
+ const names = new Set([...selectedSkillNames, ...retained(platform, 'skills')])
126
+ const actualNames = new Set()
127
+ for (const skill of scans.skills) {
128
+ if (!global && await providedToProject(skill.name, platform)) continue
129
+ if (!names.has(skill.name)) continue
130
+ let dest = path.join(roots[0], skill.name)
131
+ if (global) {
132
+ for (const root of roots) {
133
+ if (await validSkill(path.join(root, skill.name))) { dest = path.join(root, skill.name); break }
134
+ }
135
+ }
136
+ neededSkills.set(skill.name, skill)
137
+ const shared = { ...skill, sourcePath: path.join(homeDir, '.dwy', 'skills', skill.name) }
138
+ add('link-skill', `启用 ${PLATFORM_LABELS[platform]} Skill ${skill.name}(原真实目录先备份)`, [dest],
139
+ () => linkSkill({ skill: shared, dest, backupRoot }))
140
+ actualNames.add(skill.name)
141
+ if (global) addedGlobalPaths.add(dest)
142
+ }
143
+ for (const root of roots) {
144
+ if (!await fs.pathExists(root)) continue
145
+ for (const name of await fs.readdir(root)) {
146
+ if (!safeName(name) || !managed.has(name)) continue
147
+ const dest = path.join(root, name)
148
+ if (global ? names.has(name) : actualNames.has(name)) continue
149
+ const globallyProvided = !global && await providedToProject(name, platform)
150
+ add('remove-skill', `${globallyProvided ? '全局优先,清理项目重复入口' : '移除托管 Skill 入口'}:${name}(原真实目录先备份)`, [dest],
151
+ () => removeSkillEntry({ dest, backupRoot }))
152
+ if (global) removedGlobalPaths.add(dest)
153
+ }
154
+ }
155
+ platformState(platform).skills = [...new Set([...actualNames, ...retained(platform, 'skills')])].sort()
156
+ }
157
+ if (global) {
158
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('skills'))) {
159
+ const root = resolveProjectSkillDir(projectDir, platform)
160
+ if (isHome || !await fs.pathExists(root)) continue
161
+ for (const name of await fs.readdir(root)) {
162
+ if (!skillSources.has(name)) continue
163
+ let provided = false
164
+ for (const globalRoot of globalSkillRoots(platform, homeDir, codexHome)) {
165
+ const candidate = path.join(globalRoot, name)
166
+ if (addedGlobalPaths.has(candidate) || (!removedGlobalPaths.has(candidate) && await validSkill(candidate))) { provided = true; break }
167
+ }
168
+ const dest = path.join(root, name)
169
+ if (!provided || localCleanup.has(dest)) continue
170
+ localCleanup.add(dest)
171
+ add('remove-skill', `全局优先,清理当前项目重复入口:${name}(原真实目录先备份)`, [dest],
172
+ () => removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') }))
173
+ if (nextProject.platforms[platform]) {
174
+ nextProject.platforms[platform].skills = (nextProject.platforms[platform].skills || []).filter(item => item !== name)
175
+ projectChanged = true
176
+ }
177
+ }
178
+ }
179
+ }
180
+ if (neededSkills.size) {
181
+ const skills = [...neededSkills.values()]
182
+ operations.unshift({ kind: 'ensure-skills', label: '准备缺失的共享副本;已有内容不刷新',
183
+ paths: [...skills.map(skill => path.join(homeDir, '.dwy', 'skills', skill.name)), path.join(homeDir, '.dwy', 'skill-store.json')],
184
+ run: () => ensureSharedSkills({ skills, homeDir }) })
185
+ }
186
+ if ([...neededSkills.values()].some(skill => skill.category === '产品0到1')) {
187
+ const target = path.join(process.env.DWY_HOME || path.join(homeDir, '.dwy'), 'skills')
188
+ const external = await planMissingExternalSkills(target)
189
+ if (external.paths.length) add('external-skills', '补齐产品流程外部 Skills(联网拉取固定版本,保留已有内容)', external.paths, external.run)
190
+ }
191
+ }
192
+
193
+ const cliPlatforms = platforms.filter(platform => platform !== 'cursor')
194
+ if (active.has('rules')) {
195
+ const selectedNames = new Set(selected.rules.map(item => item.name))
196
+ const rules = scans.rules.filter(rule => selectedNames.has(rule.name))
197
+ const preserved = new Set(cliPlatforms.flatMap(platform => retained(platform, 'rules')))
198
+ if (cliPlatforms.length) {
199
+ add('rules', '更新项目 Rules(保留手写内容)', [path.join(projectDir, 'AGENTS.md')],
200
+ () => syncProjectAgentsMd(projectDir, rules, { preservedRuleNames: preserved }))
201
+ const names = [...new Set([...rules.map(rule => rule.name), ...preserved])].sort()
202
+ for (const platform of ['claude', 'codex', 'opencode']) {
203
+ if (platforms.includes(platform) || nextProject.platforms[platform]) {
204
+ nextProject.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
205
+ nextProject.platforms[platform].rules = names
206
+ }
207
+ }
208
+ }
209
+ if (platforms.includes('cursor')) {
210
+ const paths = rules.map(rule => path.join(projectDir, '.cursor', 'rules', cursorRuleName(rule.name)))
211
+ const root = path.join(projectDir, '.cursor', 'rules')
212
+ const managed = new Set([...scans.rules.map(rule => cursorRuleName(rule.name)), ...approved('cursor', 'rules').map(cursorRuleName), '00-dwy-global.mdc'])
213
+ if (await fs.pathExists(root)) for (const name of await fs.readdir(root)) if (managed.has(name)) paths.push(path.join(root, name))
214
+ add('rules', '同步 Cursor 项目 Rules', [...new Set(paths)], () => syncCursor({ sourceDir, projectDir, selected: { rules }, staleRemovals: staleRemovals.cursor }))
215
+ platformState('cursor').rules = [...new Set([...rules.map(rule => rule.name), ...retained('cursor', 'rules')])].sort()
216
+ }
217
+ if (platforms.includes('claude')) {
218
+ const names = new Set([...scans.rules.map(rule => rule.name), ...approved('claude', 'rules')])
219
+ for (const name of names) {
220
+ const dest = path.join(projectDir, '.claude', 'rules', name)
221
+ if (safeName(name) && await existsEntry(dest)) add('legacy-rule', '清理旧项目规则入口', [dest], () => fs.remove(dest))
222
+ }
223
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
224
+ const dest = path.join(projectDir, '.claude', name)
225
+ if (await fs.pathExists(dest)) {
226
+ const text = await fs.readFile(dest, 'utf8')
227
+ if (text.trim() === baseline.trim() || /<!-- DWY-BASELINE:START[\s\S]*?DWY-BASELINE:END -->/.test(text)) {
228
+ add('legacy-baseline', '清理旧项目基线托管内容', [dest], () => removeLegacyBaseline(baseline, dest))
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+ if (active.has('baseline')) {
235
+ const paths = { claude: path.join(homeDir, '.claude', 'CLAUDE.md'), codex: path.join(codexHome, 'AGENTS.md'), opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md') }
236
+ for (const platform of cliPlatforms) {
237
+ const dest = paths[platform]
238
+ add('baseline', `更新 ${PLATFORM_LABELS[platform]} 全局通用规范(保留手写内容)`, [dest], () => syncGlobalBaselineDoc(baseline, dest))
239
+ platformState(platform).rules = []
240
+ }
241
+ if (platforms.includes('claude')) {
242
+ const dest = path.join(homeDir, '.claude', 'AGENTS.md')
243
+ if (await fs.pathExists(dest)) {
244
+ const text = await fs.readFile(dest, 'utf8')
245
+ if (text.trim() === baseline.trim() || /<!-- DWY-BASELINE:START[\s\S]*?DWY-BASELINE:END -->/.test(text)) {
246
+ add('legacy-baseline', '清理旧全局基线托管内容', [dest], () => removeLegacyBaseline(baseline, dest))
247
+ }
248
+ }
249
+ }
250
+ }
251
+ if (active.has('hooks')) {
252
+ const manifests = await loadHookManifests(sourceDir)
253
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('hooks'))) {
254
+ const hooks = scans.hooks.filter(hook => manifests.some(manifest => manifest.name === hook.name && (!manifest.platforms || manifest.platforms.includes(platform))))
255
+ const root = global && platform === 'codex' ? codexHome : path.join(targetRoot, platform === 'claude' ? '.claude' : '.codex')
256
+ const paths = hooks.map(hook => path.join(root, 'hooks', hook.name))
257
+ const selectedNames = new Set(hooks.map(hook => hook.name))
258
+ const managedNames = new Set([...scans.hooks.map(hook => hook.name), ...approved(platform, 'hooks')])
259
+ const removedNames = []
260
+ const hookDir = path.join(root, 'hooks')
261
+ if (await fs.pathExists(hookDir)) {
262
+ for (const name of await fs.readdir(hookDir)) {
263
+ if (managedNames.has(name) && !selectedNames.has(name)) {
264
+ paths.push(path.join(hookDir, name))
265
+ removedNames.push(name)
266
+ }
267
+ }
268
+ }
269
+ paths.push(path.join(root, platform === 'claude' ? 'settings.json' : 'hooks.json'))
270
+ const sync = platform === 'claude' ? syncClaude : syncCodex
271
+ const cleanup = removedNames.length ? `;清理旧托管脚本:${removedNames.join('、')}` : ''
272
+ add('hooks', `同步 ${PLATFORM_LABELS[platform]} ${global ? '全局' : '项目'} Hooks${cleanup}`, paths,
273
+ () => sync({ sourceDir, projectDir: targetRoot, global, homeDir, codexHome, selected: { skills: [], hooks }, syncTypes: ['hooks'], staleRemovals: staleRemovals[platform] }))
274
+ platformState(platform).hooks = [...new Set([...hooks.map(hook => hook.name), ...retained(platform, 'hooks')])].sort()
275
+ }
276
+ }
277
+ return { operations, nextProject, nextGlobal, projectChanged, globalChanged: global && contentTypes.length > 0 }
278
+ }