create-dwy 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync-codex.js CHANGED
@@ -1,7 +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
+ import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
5
5
  import {
6
6
  extractDescription,
7
7
  loadHookManifests,
@@ -220,16 +220,16 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
220
220
 
221
221
  // ---------- hooks → .codex/ ----------
222
222
 
223
- export async function scanExistingCodexHooks(proj) {
224
- 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')
225
225
  if (!await fs.pathExists(dir)) return new Set()
226
226
  const entries = await fs.readdir(dir)
227
227
  return new Set(entries.filter(n => n !== '.gitkeep' && n !== '.DS_Store'))
228
228
  }
229
229
 
230
- async function copyCodexHooks(hooks, proj) {
230
+ async function copyCodexHooks(hooks, configDir) {
231
231
  if (hooks.length === 0) return 0
232
- const dir = path.join(proj, '.codex', 'hooks')
232
+ const dir = path.join(configDir, 'hooks')
233
233
  await fs.ensureDir(dir)
234
234
  for (const h of hooks) {
235
235
  if (!await fs.pathExists(h.sourcePath)) {
@@ -248,7 +248,12 @@ async function copyCodexHooks(hooks, proj) {
248
248
  return hooks.length
249
249
  }
250
250
 
251
- 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) {
252
257
  const hooksJson = { hooks: {} }
253
258
  const grouped = new Map()
254
259
 
@@ -263,7 +268,7 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
263
268
  hooks.push({
264
269
  type: 'command',
265
270
  command: global
266
- ? `bash "$HOME/.codex/hooks/${manifest.name}"`
271
+ ? globalHookCommand(configDir, manifest.name)
267
272
  : `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
268
273
  ...(manifest.timeout ? { timeout: manifest.timeout } : {}),
269
274
  })
@@ -279,13 +284,14 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
279
284
  return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
280
285
  }
281
286
 
282
- function isManagedCodexHookCommand(command, managedHookNames) {
287
+ function isManagedCodexHookCommand(command, managedHookNames, configDir) {
283
288
  if (typeof command !== 'string') return false
284
289
  const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
285
290
  return !!(match && managedHookNames.has(match[1]))
291
+ || [...managedHookNames].some(name => command === globalHookCommand(configDir, name))
286
292
  }
287
293
 
288
- function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
294
+ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
289
295
  const merged = {}
290
296
  const sourceEvents = new Set([
291
297
  ...Object.keys(existingHooks || {}),
@@ -296,7 +302,7 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
296
302
  const kept = []
297
303
 
298
304
  for (const cfg of existingHooks?.[event] || []) {
299
- const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames))
305
+ const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames, configDir))
300
306
  if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
301
307
  }
302
308
 
@@ -307,10 +313,10 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
307
313
  return Object.keys(merged).length > 0 ? merged : undefined
308
314
  }
309
315
 
310
- async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
311
- const dest = path.join(proj, '.codex', 'hooks.json')
316
+ async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global) {
317
+ const dest = path.join(configDir, 'hooks.json')
312
318
  const manifests = await loadHookManifests(sourceDir)
313
- const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
319
+ const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
314
320
  const managedHookNames = new Set(
315
321
  manifests
316
322
  .filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
@@ -320,7 +326,7 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
320
326
  let nextJson
321
327
  if (await fs.pathExists(dest)) {
322
328
  const existing = await fs.readJson(dest)
323
- const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames)
329
+ const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames, configDir)
324
330
  if (!nextHooks) {
325
331
  if (!existing.hooks) return 0
326
332
  const { hooks, ...rest } = existing
@@ -344,14 +350,16 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
344
350
  return 1
345
351
  }
346
352
 
347
- /** 同步 Codex 配置;global 模式将规则写入用户 .codex/AGENTS.md。 */
353
+ /** 同步 Codex Skills 与 Hooks;全局 Hooks 遵循 codexHome。 */
348
354
  export async function syncCodex({
349
355
  sourceDir: sourceDirOverride,
350
356
  projectDir,
351
357
  global = false,
352
358
  selected,
353
359
  homeDir,
360
+ codexHome,
354
361
  sharedSkills,
362
+ syncTypes = ['skills', 'hooks'],
355
363
  staleRemovals = {},
356
364
  } = {}) {
357
365
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -360,6 +368,7 @@ export async function syncCodex({
360
368
  }
361
369
 
362
370
  const proj = projectDir || process.cwd()
371
+ const configDir = global && codexHome ? codexHome : path.join(proj, '.codex')
363
372
 
364
373
  console.log(chalk.blue('\nScanning available config for Codex...\n'))
365
374
 
@@ -371,7 +380,7 @@ export async function syncCodex({
371
380
 
372
381
  const existing = {
373
382
  skills: await scanExistingCodexSkills(proj),
374
- hooks: await scanExistingCodexHooks(proj),
383
+ hooks: await scanExistingCodexHooks(proj, configDir),
375
384
  }
376
385
  const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
377
386
  const syncedSelection = {
@@ -385,12 +394,16 @@ export async function syncCodex({
385
394
  let syncedCount = 0
386
395
  let removedCount = 0
387
396
 
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)
392
- syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
393
- 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
+ }
394
407
 
395
408
  const skillTemplateNames = new Set([
396
409
  ...scans.skills.map(s => s.name),
@@ -400,10 +413,10 @@ export async function syncCodex({
400
413
  ...scans.hooks.map(h => h.name),
401
414
  ...(staleRemovals.hooks || new Set()),
402
415
  ])
403
- removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
404
- 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)
405
418
 
406
419
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
407
420
  console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
408
- console.log(chalk.gray(` Hooks → ${path.join(proj, '.codex')}`))
421
+ console.log(chalk.gray(` Hooks → ${configDir}`))
409
422
  }
@@ -7,7 +7,7 @@ import fs from 'fs-extra'
7
7
  import os from 'node:os'
8
8
  import path from 'path'
9
9
  import { logAction, scanExisting } from './sync.js'
10
- import { linkSkill, prepareSharedSkills, removeSkillEntry } from './skill-store.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'
@@ -123,7 +123,7 @@ 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 prepareSharedSkills({ skills, homeDir })
126
+ const sharedSkills = await ensureSharedSkills({ skills, homeDir })
127
127
 
128
128
  for (const destId of destIds) {
129
129
  const destRoot = resolveGlobalSkillDir(destId, homeDir)
@@ -149,7 +149,7 @@ export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.hom
149
149
  */
150
150
  export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills, homeDir }) {
151
151
  let count = 0
152
- const sharedSkills = await prepareSharedSkills({ skills, homeDir })
152
+ const sharedSkills = await ensureSharedSkills({ skills, homeDir })
153
153
 
154
154
  for (const platform of platforms) {
155
155
  const destRoot = resolveProjectSkillDir(projectDir, platform)
@@ -200,7 +200,7 @@ export async function scanGlobalSkills({ homeDir, codexHome }) {
200
200
  return names
201
201
  }
202
202
 
203
- /** 全局优先:已有全局副本迁为共享链接,项目重复真实目录备份后移除,返回去重名称。 */
203
+ /** 全局优先:仅备份并移除项目重复入口,不改写全局提供者,返回去重名称。 */
204
204
  export async function deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome }) {
205
205
  const projectRoot = resolveProjectSkillDir(projectDir, platform)
206
206
  const duplicates = new Set()
@@ -208,7 +208,6 @@ export async function deduplicateProjectSkills({ projectDir, platform, skills, h
208
208
  for (const skill of skills) {
209
209
  const globalEntry = await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })
210
210
  if (!globalEntry) continue
211
- await linkSkill({ skill, dest: globalEntry, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') })
212
211
  const dest = path.join(projectRoot, skill.name)
213
212
  await removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
214
213
  duplicates.add(skill.name)
@@ -1,7 +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
+ import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
5
5
  import {
6
6
  logAction,
7
7
  resolveSourceDir,
@@ -77,7 +77,7 @@ export async function syncOpenCode({
77
77
 
78
78
  const skills = sharedSkills
79
79
  ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
80
- : await prepareSharedSkills({ skills: syncedSelection.skills, homeDir })
80
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
81
81
  syncedCount += await copyItemsTo(openCodeDir, 'skills', skills)
82
82
 
83
83
  removedCount += await removeUnselected(
@@ -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
+ }
package/src/sync.js CHANGED
@@ -3,7 +3,7 @@ import path from 'path'
3
3
  import { isCancel } from '@clack/prompts'
4
4
  import { tabMultiselect } from './tab-multiselect.js'
5
5
  import { chalk, PACKAGE_ROOT } from './utils.js'
6
- import { linkSkill, prepareSharedSkills, removeSkillEntry } from './skill-store.js'
6
+ import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
7
7
 
8
8
  const CATEGORIES = [
9
9
  { key: 'skills', label: 'Skills' },
@@ -195,8 +195,9 @@ export function buildCategoryTabs(items, type) {
195
195
  if (!groups.has(category)) groups.set(category, [])
196
196
  groups.get(category).push({
197
197
  value: item.name,
198
- label: item.name,
198
+ label: item.label || item.name,
199
199
  description: item.description,
200
+ disabled: item.disabled,
200
201
  type,
201
202
  })
202
203
  }
@@ -210,21 +211,21 @@ export function buildCategoryTabs(items, type) {
210
211
  }
211
212
 
212
213
  /**
213
- * 顺序选择 Skills、Rules;Enter 进入下一类,不再提供回退或重选菜单。
214
- * 完整同步默认启用全部模板 Hooks;仅 Skills 模式不得写入其他配置。
214
+ * 只展开调用方点名的内容,Enter 进入下一类,不提供回退菜单。
215
215
  * 取消返回 null,调用方必须终止同步。
216
216
  *
217
217
  * @param {object} scans
218
218
  * @param {{ skills: Iterable<string>, rules: Iterable<string> }} existing
219
- * @param {{ skillsOnly?: boolean }} [opts]
219
+ * @param {{ skillsOnly?: boolean, types?: string[] }} [opts]
220
220
  */
221
- export async function interactiveSelect(scans, existing, { skillsOnly = false } = {}) {
222
- const selected = { skills: [], rules: [], commands: [], hooks: skillsOnly ? [] : scans.hooks }
223
- for (const { key, label } of skillsOnly ? CATEGORIES.slice(0, 1) : CATEGORIES) {
221
+ export async function interactiveSelect(scans, existing, { skillsOnly = false, types } = {}) {
222
+ const activeTypes = types || (skillsOnly ? ['skills'] : ['skills', 'rules', 'hooks'])
223
+ const selected = { skills: [], rules: [], commands: [], hooks: activeTypes.includes('hooks') ? scans.hooks : [] }
224
+ for (const { key, label } of CATEGORIES.filter(item => activeTypes.includes(item.key))) {
224
225
  const tabs = buildCategoryTabs(scans[key], key)
225
- const availableNames = new Set(scans[key].map(item => item.name))
226
+ const availableNames = new Set(scans[key].filter(item => !item.disabled).map(item => item.name))
226
227
  const names = await tabMultiselect({
227
- message: `选择 ${label}`,
228
+ message: `选择 ${label}(取消已有勾选将移除当前范围的托管项)`,
228
229
  tabs,
229
230
  initialValues: [...existing[key]].filter(name => availableNames.has(name)),
230
231
  required: false,
@@ -406,6 +407,7 @@ export async function syncClaude({
406
407
  selected,
407
408
  homeDir,
408
409
  sharedSkills,
410
+ syncTypes = ['skills', 'rules', 'hooks'],
409
411
  staleRemovals = {},
410
412
  } = {}) {
411
413
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -461,19 +463,23 @@ export async function syncClaude({
461
463
  let syncedCount = 0
462
464
  let removedCount = 0
463
465
 
464
- const skills = sharedSkills
465
- ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
466
- : await prepareSharedSkills({ skills: syncedSelection.skills, homeDir })
467
- syncedCount += await copyItems(skills, projectTargetDir)
468
- syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global)
469
- syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
466
+ if (syncTypes.includes('skills')) {
467
+ const skills = sharedSkills
468
+ ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
469
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
470
+ syncedCount += await copyItems(skills, projectTargetDir)
471
+ }
472
+ if (syncTypes.includes('hooks')) {
473
+ syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global)
474
+ syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
475
+ }
470
476
 
471
477
  const skillTemplateNames = new Set([...scans.skills.map(s => s.name), ...approvedStaleSkills])
472
478
  const ruleTemplateNames = new Set([...scans.rules.map(r => r.name), ...approvedStaleRules])
473
479
  const hookTemplateNames = new Set([...scans.hooks.map(h => h.name), ...approvedStaleHooks])
474
- removedCount += await removeUnselected('skills', existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, projectTargetDir)
475
- removedCount += await removeUnselected('rules', existing.rules, new Set(), ruleTemplateNames, projectTargetDir)
476
- removedCount += await removeUnselected('hooks', existing.hooks, selectedHookNames, hookTemplateNames, projectTargetDir)
480
+ if (syncTypes.includes('skills')) removedCount += await removeUnselected('skills', existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, projectTargetDir)
481
+ if (syncTypes.includes('rules')) removedCount += await removeUnselected('rules', existing.rules, new Set(), ruleTemplateNames, projectTargetDir)
482
+ if (syncTypes.includes('hooks')) removedCount += await removeUnselected('hooks', existing.hooks, selectedHookNames, hookTemplateNames, projectTargetDir)
477
483
 
478
484
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
479
485
  console.log(chalk.gray(` Project config → ${projectTargetDir}`))
@@ -72,8 +72,8 @@ function symbol(state) {
72
72
  */
73
73
  export function tabSelectionStats(tab, selectedValues) {
74
74
  const set = selectedValues instanceof Set ? selectedValues : new Set(selectedValues)
75
- const total = tab.options.length
76
- const selected = tab.options.filter(option => set.has(option.value)).length
75
+ const total = tab.options.filter(option => !option.disabled).length
76
+ const selected = tab.options.filter(option => !option.disabled && set.has(option.value)).length
77
77
  return { selected, total }
78
78
  }
79
79
 
@@ -211,7 +211,8 @@ class TabMultiSelectPrompt extends Prompt {
211
211
  constructor(opts) {
212
212
  super(opts, false)
213
213
  this.tabs = opts.tabs
214
- this.value = [...(opts.initialValues ?? [])]
214
+ const selectable = new Set(this.tabs.flatMap(tab => tab.options.filter(option => !option.disabled).map(option => option.value)))
215
+ this.value = (opts.initialValues ?? []).filter(value => selectable.has(value))
215
216
  this.cursors = this.tabs.map(() => 0)
216
217
  this.tabIndex = firstTabWithSelection(this.tabs, this.value)
217
218
  this.on('cursor', (action) => this.#onCursor(action))
@@ -248,7 +249,7 @@ class TabMultiSelectPrompt extends Prompt {
248
249
  /** Space:只动光标那一条。 */
249
250
  toggleValue() {
250
251
  const option = this.currentOptions[this.cursor]
251
- if (!option) return
252
+ if (!option || option.disabled) return
252
253
  const selected = this.value.includes(option.value)
253
254
  this.value = selected
254
255
  ? this.value.filter(value => value !== option.value)
@@ -257,7 +258,7 @@ class TabMultiSelectPrompt extends Prompt {
257
258
 
258
259
  /** a:当前 Tab 全选或全不选。 */
259
260
  toggleAllCurrentTab() {
260
- const tabValues = this.currentOptions.map(option => option.value)
261
+ const tabValues = this.currentOptions.filter(option => !option.disabled).map(option => option.value)
261
262
  this.value = toggleTabValues(this.value, tabValues)
262
263
  }
263
264
  }
@@ -384,6 +385,7 @@ export function tabMultiselect(opts) {
384
385
  label: opt.label ?? String(opt.value ?? ''),
385
386
  selected: selected.includes(opt.value),
386
387
  active,
388
+ disabled: opt.disabled,
387
389
  }),
388
390
  })
389
391