create-dwy 0.22.0 → 0.24.1

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.
@@ -0,0 +1,347 @@
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 { hasManagedBaseline, removeLegacyBaseline, syncBaselineDoc, syncCodex, syncProjectAgentsMd } from './sync-codex.js'
7
+ import { syncCursor } from './sync-cursor.js'
8
+ import { planMissingExternalSkills } from './skills-install.js'
9
+ import { assertConfigPath, assertHooksEnabled, baselineProviders, globalBaselinePath, globalHookNames, hasManagedHookConfig, hookConfigFile, removeManagedHooks } from './sync-inheritance.js'
10
+
11
+ /** 平台显示名,交互和变更清单共用。 */
12
+ export const PLATFORM_LABELS = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', cursor: 'Cursor' }
13
+ /** 平台能力;全局不提供 Rules,Cursor 仅提供项目 Rules。 */
14
+ export const PLATFORM_SYNC_TYPES = {
15
+ claude: ['skills', 'rules', 'hooks', 'baseline'], codex: ['skills', 'rules', 'hooks', 'baseline'],
16
+ opencode: ['skills', 'rules', 'baseline'], cursor: ['rules'],
17
+ }
18
+
19
+ /** 只提供所选范围和平台支持的内容;未勾选类型不进入清理或状态更新。 */
20
+ export function contentOptions(scope, platforms) {
21
+ const supports = type => platforms.some(platform => PLATFORM_SYNC_TYPES[platform]?.includes(type))
22
+ return [
23
+ ...(supports('skills') ? [{ value: 'skills', label: 'Skills', description: '管理当前范围的启用入口;已有共享内容不刷新' }] : []),
24
+ ...(scope === 'project' && supports('rules') ? [{ value: 'rules', label: '项目规则(Rules)', description: '只更新当前项目的托管规则' }] : []),
25
+ ...(supports('baseline') ? [{ value: 'baseline', label: 'AGENTS.md 通用规范', description: scope === 'global'
26
+ ? '管理原生全局指令入口;首次默认启用,可显式移除' : '管理项目基线块;已有全局规范时仅可清理重复项' }] : []),
27
+ ...(supports('hooks') ? [{ value: 'hooks', label: 'Hooks', description: '独立启用或移除整组;项目继承有效全局注册' }] : []),
28
+ ]
29
+ }
30
+
31
+ /** 为项目 Skills 标注平台覆盖;只有全部适用平台已全局提供时才禁止重复选择。 */
32
+ export async function projectSkillChoices(skills, platforms, { homeDir, codexHome }) {
33
+ const applicable = platforms.filter(platform => PLATFORM_SYNC_TYPES[platform]?.includes('skills'))
34
+ return Promise.all(skills.map(async skill => {
35
+ const covered = []
36
+ for (const platform of applicable) {
37
+ if (await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) covered.push(platform)
38
+ }
39
+ const disabled = applicable.length > 0 && covered.length === applicable.length
40
+ const missing = applicable.filter(platform => !covered.includes(platform))
41
+ const status = disabled ? '全局已启用' : covered.length ? `${missing.map(p => PLATFORM_LABELS[p]).join('、')} 可安装到项目` : ''
42
+ return { ...skill, disabled, label: status ? `${skill.name}(${status})` : skill.name,
43
+ description: [covered.length ? `全局已提供:${covered.map(p => PLATFORM_LABELS[p]).join('、')}` : '', skill.description].filter(Boolean).join(';') }
44
+ }))
45
+ }
46
+
47
+ const safeName = name => typeof name === 'string' && name.length > 0 && name !== '.' && name !== '..' && path.basename(name) === name && !/[\\/]/.test(name)
48
+ const cursorRuleName = name => `dwy-${name.replace(/\.md$/i, '')}.mdc`
49
+
50
+ async function existsEntry(dest) {
51
+ return fs.lstat(dest).then(() => true).catch(error => {
52
+ if (error.code === 'ENOENT') return false
53
+ throw error
54
+ })
55
+ }
56
+
57
+ async function validSkill(dest) {
58
+ const stat = await fs.stat(path.join(dest, 'SKILL.md')).catch(error => {
59
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
60
+ throw error
61
+ })
62
+ return Boolean(stat?.isFile())
63
+ }
64
+
65
+ function ownedGlobalRoots(platform, homeDir, codexHome) {
66
+ if (platform === 'codex') return globalSkillRoots(platform, homeDir, codexHome)
67
+ return [resolveGlobalSkillDir(platform, homeDir)]
68
+ }
69
+
70
+ /**
71
+ * 只读计算本次全部操作和下一份状态;run 回调只在确认后执行。
72
+ * Skills 的入口与内容刷新分离;项目去重只删除项目入口,不改写全局提供者。
73
+ */
74
+ export async function buildSyncPlan({
75
+ scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
76
+ projectState, globalState, staleRemovals = {}, staleEntries = {}, baseline,
77
+ contentActions = {},
78
+ skillScope = { destinations: ['project'], globalSkills: [] },
79
+ }) {
80
+ const global = scope === 'global'
81
+ const isHome = path.resolve(projectDir) === path.resolve(homeDir)
82
+ || (await fs.pathExists(projectDir) && await fs.pathExists(homeDir)
83
+ && await fs.realpath(projectDir) === await fs.realpath(homeDir))
84
+ if (!global && isHome) {
85
+ throw new Error('当前目录是用户主目录,请选择全局范围或进入项目目录')
86
+ }
87
+ const allowed = new Set(contentOptions(scope, platforms).map(option => option.value))
88
+ if (platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))
89
+ || contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
90
+ const active = new Set(contentTypes)
91
+ for (const [type, action] of Object.entries(contentActions)) {
92
+ if (!['baseline', 'hooks'].includes(type) || !['sync', 'remove'].includes(action)) throw new Error('无效的内容操作')
93
+ }
94
+ if (active.has('baseline') && contentActions.baseline !== 'remove' && !baseline.trim()) throw new Error('没有可同步的通用规范')
95
+ const operations = []
96
+ const nextProject = structuredClone(projectState), nextGlobal = structuredClone(globalState)
97
+ const targetState = global ? nextGlobal : nextProject
98
+ const targetRoot = global ? homeDir : projectDir
99
+ const selectedSkillNames = new Set(selected.skills.map(item => item.name))
100
+ const skillSources = new Map(scans.skills.map(item => [item.name, item]))
101
+ const neededSkills = new Map()
102
+ const addedGlobalPaths = new Set(), removedGlobalPaths = new Set()
103
+ const localCleanup = new Set()
104
+ let projectChanged = !global && contentTypes.length > 0
105
+ const add = (kind, label, paths, run) => {
106
+ // 无标记旧基线须在 Rules 追加前识别,否则原文会变成无法确认归属的混合内容。
107
+ const beforeRules = ['baseline', 'remove-baseline'].includes(kind) ? operations.findIndex(operation => operation.kind === 'rules') : -1
108
+ operations.splice(beforeRules < 0 ? operations.length : beforeRules, 0, { kind, label, paths, run })
109
+ }
110
+ const addConfig = async (kind, label, paths, boundary, run) => {
111
+ for (const dest of paths) await assertConfigPath(dest, boundary)
112
+ add(kind, label, paths, async () => {
113
+ for (const dest of paths) await assertConfigPath(dest, boundary)
114
+ await run()
115
+ })
116
+ }
117
+ const platformState = platform => targetState.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
118
+ const approved = (platform, type) => [...(staleRemovals[platform]?.[type] || [])].filter(safeName)
119
+ const retained = (platform, type) => (staleEntries[platform]?.[type] || []).filter(name => safeName(name) && !approved(platform, type).includes(name))
120
+ const backupRoot = path.join(targetRoot, '.dwy', 'skill-backups')
121
+ const providedToProject = async (name, platform) => globalSkillRoots(platform, homeDir, codexHome)
122
+ .some(root => addedGlobalPaths.has(path.join(root, name))) || Boolean(await findGlobalSkill({ name, platform, homeDir, codexHome }))
123
+
124
+ if (active.has('skills')) {
125
+ for (const destination of skillScope.destinations.filter(id => id !== 'project')) {
126
+ const root = resolveGlobalSkillDir(destination, homeDir)
127
+ if (!root) continue
128
+ for (const name of skillScope.globalSkills) {
129
+ const skill = skillSources.get(name)
130
+ if (!skill) continue
131
+ neededSkills.set(name, skill)
132
+ const dest = path.join(root, name), shared = { ...skill, sourcePath: path.join(homeDir, '.dwy', 'skills', name) }
133
+ add('link-skill', `显式脚本配置的额外全局入口:${name}`, [dest],
134
+ () => linkSkill({ skill: shared, dest, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') }))
135
+ addedGlobalPaths.add(dest)
136
+ }
137
+ }
138
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('skills'))) {
139
+ if (!global && !skillScope.destinations.includes('project')) continue
140
+ const roots = global ? ownedGlobalRoots(platform, homeDir, codexHome) : [resolveProjectSkillDir(projectDir, platform)]
141
+ const managed = new Set([...skillSources.keys(), ...approved(platform, 'skills')])
142
+ const names = new Set([...selectedSkillNames, ...retained(platform, 'skills')])
143
+ const actualNames = new Set()
144
+ for (const skill of scans.skills) {
145
+ if (!global && await providedToProject(skill.name, platform)) continue
146
+ if (!names.has(skill.name)) continue
147
+ let dest = path.join(roots[0], skill.name)
148
+ if (global) {
149
+ for (const root of roots) {
150
+ if (await validSkill(path.join(root, skill.name))) { dest = path.join(root, skill.name); break }
151
+ }
152
+ }
153
+ neededSkills.set(skill.name, skill)
154
+ const shared = { ...skill, sourcePath: path.join(homeDir, '.dwy', 'skills', skill.name) }
155
+ add('link-skill', `启用 ${PLATFORM_LABELS[platform]} Skill ${skill.name}(原真实目录先备份)`, [dest],
156
+ () => linkSkill({ skill: shared, dest, backupRoot }))
157
+ actualNames.add(skill.name)
158
+ if (global) addedGlobalPaths.add(dest)
159
+ }
160
+ for (const root of roots) {
161
+ if (!await fs.pathExists(root)) continue
162
+ for (const name of await fs.readdir(root)) {
163
+ if (!safeName(name) || !managed.has(name)) continue
164
+ const dest = path.join(root, name)
165
+ if (global ? names.has(name) : actualNames.has(name)) continue
166
+ const globallyProvided = !global && await providedToProject(name, platform)
167
+ add('remove-skill', `${globallyProvided ? '全局优先,清理项目重复入口' : '移除托管 Skill 入口'}:${name}(原真实目录先备份)`, [dest],
168
+ () => removeSkillEntry({ dest, backupRoot }))
169
+ if (global) removedGlobalPaths.add(dest)
170
+ }
171
+ }
172
+ platformState(platform).skills = [...new Set([...actualNames, ...retained(platform, 'skills')])].sort()
173
+ }
174
+ if (global) {
175
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('skills'))) {
176
+ const root = resolveProjectSkillDir(projectDir, platform)
177
+ if (isHome || !await fs.pathExists(root)) continue
178
+ for (const name of await fs.readdir(root)) {
179
+ if (!skillSources.has(name)) continue
180
+ let provided = false
181
+ for (const globalRoot of globalSkillRoots(platform, homeDir, codexHome)) {
182
+ const candidate = path.join(globalRoot, name)
183
+ if (addedGlobalPaths.has(candidate) || (!removedGlobalPaths.has(candidate) && await validSkill(candidate))) { provided = true; break }
184
+ }
185
+ const dest = path.join(root, name)
186
+ if (!provided || localCleanup.has(dest)) continue
187
+ localCleanup.add(dest)
188
+ add('remove-skill', `全局优先,清理当前项目重复入口:${name}(原真实目录先备份)`, [dest],
189
+ () => removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') }))
190
+ if (nextProject.platforms[platform]) {
191
+ nextProject.platforms[platform].skills = (nextProject.platforms[platform].skills || []).filter(item => item !== name)
192
+ projectChanged = true
193
+ }
194
+ }
195
+ }
196
+ }
197
+ if (neededSkills.size) {
198
+ const skills = [...neededSkills.values()]
199
+ operations.unshift({ kind: 'ensure-skills', label: '准备缺失的共享副本;已有内容不刷新',
200
+ paths: [...skills.map(skill => path.join(homeDir, '.dwy', 'skills', skill.name)), path.join(homeDir, '.dwy', 'skill-store.json')],
201
+ run: () => ensureSharedSkills({ skills, homeDir }) })
202
+ }
203
+ if ([...neededSkills.values()].some(skill => skill.category === '产品0到1')) {
204
+ const target = path.join(process.env.DWY_HOME || path.join(homeDir, '.dwy'), 'skills')
205
+ const external = await planMissingExternalSkills(target)
206
+ if (external.paths.length) add('external-skills', '补齐产品流程外部 Skills(联网拉取固定版本,保留已有内容)', external.paths, external.run)
207
+ }
208
+ }
209
+
210
+ const cliPlatforms = platforms.filter(platform => platform !== 'cursor')
211
+ if (active.has('rules')) {
212
+ const selectedNames = new Set(selected.rules.map(item => item.name))
213
+ const rules = scans.rules.filter(rule => selectedNames.has(rule.name))
214
+ const preserved = new Set(cliPlatforms.flatMap(platform => retained(platform, 'rules')))
215
+ if (cliPlatforms.length) {
216
+ await addConfig('rules', '更新项目 Rules(保留通用规范和手写内容)', [path.join(projectDir, 'AGENTS.md')], projectDir,
217
+ () => syncProjectAgentsMd(projectDir, rules, { preservedRuleNames: preserved }))
218
+ const names = [...new Set([...rules.map(rule => rule.name), ...preserved])].sort()
219
+ for (const platform of ['claude', 'codex', 'opencode']) {
220
+ if (platforms.includes(platform) || nextProject.platforms[platform]) {
221
+ nextProject.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
222
+ nextProject.platforms[platform].rules = names
223
+ }
224
+ }
225
+ }
226
+ if (platforms.includes('cursor')) {
227
+ const paths = rules.map(rule => path.join(projectDir, '.cursor', 'rules', cursorRuleName(rule.name)))
228
+ const root = path.join(projectDir, '.cursor', 'rules')
229
+ const managed = new Set([...scans.rules.map(rule => cursorRuleName(rule.name)), ...approved('cursor', 'rules').map(cursorRuleName), '00-dwy-global.mdc'])
230
+ if (await fs.pathExists(root)) for (const name of await fs.readdir(root)) if (managed.has(name)) paths.push(path.join(root, name))
231
+ add('rules', '同步 Cursor 项目 Rules', [...new Set(paths)], () => syncCursor({ sourceDir, projectDir, selected: { rules }, staleRemovals: staleRemovals.cursor }))
232
+ platformState('cursor').rules = [...new Set([...rules.map(rule => rule.name), ...retained('cursor', 'rules')])].sort()
233
+ }
234
+ if (platforms.includes('claude')) {
235
+ const names = new Set([...scans.rules.map(rule => rule.name), ...approved('claude', 'rules')])
236
+ for (const name of names) {
237
+ const dest = path.join(projectDir, '.claude', 'rules', name)
238
+ if (safeName(name) && await existsEntry(dest)) add('legacy-rule', '清理旧项目规则入口', [dest], () => fs.remove(dest))
239
+ }
240
+ }
241
+ }
242
+ if (active.has('baseline')) {
243
+ const remove = contentActions.baseline === 'remove'
244
+ const providers = global ? [] : await baselineProviders(cliPlatforms, { homeDir, codexHome })
245
+ const inherited = providers.length > 0
246
+ const markProject = enabled => {
247
+ for (const platform of ['claude', 'codex', 'opencode']) {
248
+ if ((!global && platforms.includes(platform)) || nextProject.platforms[platform]) {
249
+ nextProject.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
250
+ nextProject.platforms[platform].baseline = enabled
251
+ if (global) projectChanged = true
252
+ }
253
+ }
254
+ }
255
+ const cleanBaseline = async (dest, boundary, label) => {
256
+ if (!await fs.pathExists(dest)) return false
257
+ const text = await fs.readFile(dest, 'utf8')
258
+ if (!await hasManagedBaseline(dest) && (!baseline.trim() || text.trim() !== baseline.trim())) return false
259
+ await addConfig('remove-baseline', label, [dest], boundary, () => text.trim() === baseline.trim()
260
+ ? removeLegacyBaseline(baseline, dest) : syncBaselineDoc('', dest))
261
+ return true
262
+ }
263
+ if (global) {
264
+ for (const platform of cliPlatforms) {
265
+ const dest = globalBaselinePath(platform, homeDir, codexHome)
266
+ if (remove) await cleanBaseline(dest, platform === 'codex' ? codexHome : homeDir, `移除 ${PLATFORM_LABELS[platform]} 全局通用规范(保留 Rules 和手写内容)`)
267
+ else await addConfig('baseline', `启用/更新 ${PLATFORM_LABELS[platform]} 全局通用规范(保留其他内容)`, [dest], platform === 'codex' ? codexHome : homeDir, () => syncBaselineDoc(baseline, dest))
268
+ platformState(platform).baseline = !remove
269
+ }
270
+ if (platforms.includes('claude')) await cleanBaseline(path.join(homeDir, '.claude', 'AGENTS.md'), homeDir, '清理旧全局基线托管内容')
271
+ } else {
272
+ const dest = path.join(projectDir, 'AGENTS.md')
273
+ if (remove || inherited) {
274
+ await cleanBaseline(dest, projectDir, inherited ? '全局已启用,清理项目重复基线' : '移除项目通用规范(保留 Rules 和手写内容)')
275
+ if (inherited) add('inherit', `通用规范使用全局:${providers.map(p => PLATFORM_LABELS[p]).join('、')};项目禁止重复安装`,
276
+ providers.map(p => globalBaselinePath(p, homeDir, codexHome)), async () => {})
277
+ } else await addConfig('baseline', '启用/更新项目 AGENTS.md 通用规范(保留 Rules 和手写内容)', [dest], projectDir, () => syncBaselineDoc(baseline, dest))
278
+ markProject(!remove && !inherited)
279
+ }
280
+ if ((!global || !remove) && !isHome) {
281
+ if (global && await cleanBaseline(path.join(projectDir, 'AGENTS.md'), projectDir, '全局优先,清理当前项目重复基线')) markProject(false)
282
+ if (platforms.includes('claude')) {
283
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
284
+ await cleanBaseline(path.join(projectDir, '.claude', name), projectDir, '清理旧项目基线托管内容')
285
+ }
286
+ }
287
+ }
288
+ }
289
+ if (active.has('hooks')) {
290
+ const manifests = await loadHookManifests(sourceDir)
291
+ const remove = contentActions.hooks === 'remove'
292
+ const removeHooks = async (platform, root, boundary, names, label) => {
293
+ const paths = []
294
+ for (const name of names) {
295
+ const dest = path.join(root, 'hooks', name)
296
+ if (await existsEntry(dest)) paths.push(dest)
297
+ }
298
+ if (await hasManagedHookConfig(platform, root, names)) paths.push(hookConfigFile(platform, root))
299
+ if (!paths.length) return false
300
+ await addConfig('remove-hooks', label, paths, boundary, () => removeManagedHooks(platform, root, names, paths))
301
+ return true
302
+ }
303
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('hooks'))) {
304
+ const available = scans.hooks.filter(hook => manifests.some(manifest => manifest.name === hook.name && (!manifest.platforms || manifest.platforms.includes(platform))))
305
+ const provided = !global && !remove ? await globalHookNames({ platform, sourceDir, homeDir, codexHome }) : new Set()
306
+ const hooks = remove ? [] : available.filter(hook => !provided.has(hook.name))
307
+ const root = global && platform === 'codex' ? codexHome : path.join(targetRoot, platform === 'claude' ? '.claude' : '.codex')
308
+ const boundary = global && platform === 'codex' ? codexHome : targetRoot
309
+ const historical = (targetState.platforms[platform]?.hooks || []).filter(safeName)
310
+ const allManaged = new Set([...scans.hooks.map(hook => hook.name), ...(remove ? historical : []), ...approved(platform, 'hooks')])
311
+ if (remove || hooks.length === 0) {
312
+ await removeHooks(platform, root, boundary, allManaged, `${remove ? '移除' : '全局已启用,清理重复'} ${PLATFORM_LABELS[platform]} ${global ? '全局' : '项目'} 托管 Hooks`)
313
+ } else {
314
+ await assertHooksEnabled(platform, root)
315
+ const paths = hooks.map(hook => path.join(root, 'hooks', hook.name))
316
+ const selectedNames = new Set(hooks.map(hook => hook.name))
317
+ const removedNames = []
318
+ const hookDir = path.join(root, 'hooks')
319
+ if (await fs.pathExists(hookDir)) {
320
+ for (const name of await fs.readdir(hookDir)) {
321
+ if (allManaged.has(name) && !selectedNames.has(name)) {
322
+ paths.push(path.join(hookDir, name))
323
+ removedNames.push(name)
324
+ }
325
+ }
326
+ }
327
+ paths.push(hookConfigFile(platform, root))
328
+ const sync = platform === 'claude' ? syncClaude : syncCodex
329
+ const cleanup = removedNames.length ? `;清理旧托管脚本:${removedNames.join('、')}` : ''
330
+ await addConfig('hooks', `同步 ${PLATFORM_LABELS[platform]} ${global ? '全局' : '项目'} Hooks${cleanup}`, paths, boundary,
331
+ () => sync({ sourceDir, projectDir: targetRoot, global, homeDir, codexHome, selected: { skills: [], hooks }, syncTypes: ['hooks'], staleRemovals: staleRemovals[platform] }))
332
+ }
333
+ if (provided.size) add('inherit', `${PLATFORM_LABELS[platform]} Hooks 使用全局:${[...provided].join('、')};项目不重复安装`, [], async () => {})
334
+ platformState(platform).hooks = remove ? [] : [...new Set([...hooks.map(hook => hook.name), ...retained(platform, 'hooks')])].sort()
335
+ if (global && !remove && !isHome) {
336
+ const local = path.join(projectDir, platform === 'claude' ? '.claude' : '.codex')
337
+ const duplicates = new Set(available.map(hook => hook.name))
338
+ if (await removeHooks(platform, local, projectDir, duplicates, `全局优先,清理当前项目 ${PLATFORM_LABELS[platform]} 重复 Hooks`)
339
+ && nextProject.platforms[platform]) {
340
+ nextProject.platforms[platform].hooks = (nextProject.platforms[platform].hooks || []).filter(name => !duplicates.has(name))
341
+ projectChanged = true
342
+ }
343
+ }
344
+ }
345
+ }
346
+ return { operations, nextProject, nextGlobal, projectChanged, globalChanged: global && contentTypes.length > 0 }
347
+ }
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,
@@ -289,7 +290,8 @@ async function removeUnselected(typePlural, existingNames, selectedNames, templa
289
290
  return count
290
291
  }
291
292
 
292
- function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
293
+ /** 生成 Claude Hook 注册,继承检测和写入共用相同命令格式。 */
294
+ export function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
293
295
  const grouped = new Map()
294
296
 
295
297
  for (const manifest of manifests) {
@@ -319,13 +321,18 @@ function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
319
321
  return settings
320
322
  }
321
323
 
322
- function isManagedClaudeHookCommand(command, managedHookNames) {
324
+ /** 仅识别完整的 dwy 生成命令,避免误删提到同名路径的个人命令。 */
325
+ export function isManagedClaudeHookCommand(command, managedHookNames) {
323
326
  if (typeof command !== 'string') return false
324
- const match = command.match(/\.claude\/hooks\/([^\s'"]+)/)
325
- return !!(match && managedHookNames.has(match[1]))
327
+ return [...managedHookNames].some(name => [
328
+ `bash "$HOME/.claude/hooks/${name}"`,
329
+ `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}"`,
330
+ `bash ~/.claude/hooks/${name}`,
331
+ ].includes(command))
326
332
  }
327
333
 
328
- function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
334
+ /** 替换指定托管 Hook 注册,保留其他命令及配置字段。 */
335
+ export function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
329
336
  const merged = {}
330
337
  const sourceEvents = new Set([
331
338
  ...Object.keys(existingHooks || {}),
@@ -347,14 +354,12 @@ function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
347
354
  return Object.keys(merged).length > 0 ? merged : undefined
348
355
  }
349
356
 
350
- async function syncSettings(sourceDir, targetDir, selectedHookNames, global) {
357
+ async function syncSettings(sourceDir, targetDir, selectedHookNames, global, staleHookNames) {
351
358
  const settingsDest = path.join(targetDir, 'settings.json')
352
359
  const manifests = await loadHookManifests(sourceDir)
353
360
  const generatedSettings = buildClaudeHooksSettings(manifests, selectedHookNames, global)
354
361
  const managedHookNames = new Set(
355
- manifests
356
- .filter(manifest => !manifest.platforms || manifest.platforms.includes('claude'))
357
- .map(manifest => manifest.name),
362
+ [...manifests.map(manifest => manifest.name), ...staleHookNames],
358
363
  )
359
364
 
360
365
  let merged
@@ -406,6 +411,7 @@ export async function syncClaude({
406
411
  selected,
407
412
  homeDir,
408
413
  sharedSkills,
414
+ syncTypes = ['skills', 'rules', 'hooks'],
409
415
  staleRemovals = {},
410
416
  } = {}) {
411
417
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -461,19 +467,23 @@ export async function syncClaude({
461
467
  let syncedCount = 0
462
468
  let removedCount = 0
463
469
 
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)
470
+ if (syncTypes.includes('skills')) {
471
+ const skills = sharedSkills
472
+ ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
473
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
474
+ syncedCount += await copyItems(skills, projectTargetDir)
475
+ }
476
+ if (syncTypes.includes('hooks')) {
477
+ syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global, approvedStaleHooks)
478
+ syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
479
+ }
470
480
 
471
481
  const skillTemplateNames = new Set([...scans.skills.map(s => s.name), ...approvedStaleSkills])
472
482
  const ruleTemplateNames = new Set([...scans.rules.map(r => r.name), ...approvedStaleRules])
473
483
  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)
484
+ if (syncTypes.includes('skills')) removedCount += await removeUnselected('skills', existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, projectTargetDir)
485
+ if (syncTypes.includes('rules')) removedCount += await removeUnselected('rules', existing.rules, new Set(), ruleTemplateNames, projectTargetDir)
486
+ if (syncTypes.includes('hooks')) removedCount += await removeUnselected('hooks', existing.hooks, selectedHookNames, hookTemplateNames, projectTargetDir)
477
487
 
478
488
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
479
489
  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