create-dwy 0.23.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # create-dwy
2
2
 
3
+ ## [0.24.1] - 2026-11-03
4
+
5
+ ### Patch Changes
6
+
7
+ - 包含 0.24.0 的 Skills / Hooks / Rules / 通用规范管理、显式移除和全局去重能力。
8
+ - 收敛配置管理集成测试的无断言日志,规避 Node 22 测试运行器在大量中文 stdout 下的 IPC 反序列化问题;保留全部断言和真实终端输出测试,不改变 CLI 行为。
9
+
10
+ ---
11
+
12
+ ## [0.24.0] - 2026-11-03
13
+
14
+ ### Minor Changes
15
+
16
+ - 全局提供 Skills、Hooks 和 AGENTS.md 通用规范,项目另外提供 Rules。全局首次默认启用通用规范;Hooks 与规范增加显式启用/更新和移除动作,未选择的类型保持原样,规范移除后不自动复选。
17
+ - 项目通用规范与 Rules 分别操作各自托管块,移除任一项保留其他内容。任一所选 CLI 已有有效全局基线时,项目禁止重复安装,共享项目文件的混合平台场景提示补齐缺失的全局入口。
18
+ - Hooks 按有效脚本及对应事件、matcher 和完整命令注册判断全局提供者,项目只补缺失项。全局启用后明确清理当前项目重复脚本和注册项;移除全局配置不自动回填项目。
19
+ - Hook 清理支持历史托管项,保留同名个人路径、组合命令和其他设置;配置目录及单文件链接在规划和执行时检查,防止项目操作穿透改写全局或外部目标。
20
+ - 补充配置独立启停、移除记忆、继承、取消及真实终端回归,旧测试统一注入临时用户目录。
21
+
22
+ ---
23
+
3
24
  ## [0.23.0] - 2026-11-02
4
25
 
5
26
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "项目脚手架与多平台 AI 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli-help.js CHANGED
@@ -25,10 +25,10 @@ export function buildHelpText(version) {
25
25
  '同步步骤:',
26
26
  ' 1. 选择同步范围:当前项目或全局。',
27
27
  ' 2. 选择该范围支持的平台;全局不提供 Cursor。',
28
- ' 3. 选择本次处理内容,默认只选 Skills;未选择的类型保持原样。',
29
- ' 项目:Skills、项目 Rules、Hooks;全局:Skills、全局通用规范、Hooks。',
30
- ' 内容选项按平台能力过滤。Hooks 选中类型后按平台自动处理。',
31
- ' 4. 逐项选择 Skills / 项目 Rules;全局通用规范显示摘要。',
28
+ ' 3. 选择本次处理内容;项目默认只选 Skills,全局首次默认 Skills 和通用规范。',
29
+ ' 项目:Skills、Hooks、Rules、AGENTS.md 通用规范;全局:Skills、Hooks、AGENTS.md 通用规范。',
30
+ ' 内容按平台能力过滤;未选择的类型保持原样。',
31
+ ' 4. 逐项选择 Skills / Rules;Hooks 和通用规范分别选择启用/更新或移除。',
32
32
  ' 5. 查看写入、备份和清理清单,确认后执行;取消不写文件。',
33
33
  ' Commands 不参与同步。',
34
34
  '',
@@ -39,7 +39,8 @@ export function buildHelpText(version) {
39
39
  '',
40
40
  '常见用法:',
41
41
  ' 同步当前项目:cd your-project 后执行 dwy,选择「当前项目」及本次处理内容。',
42
- ' 同步全局:执行 dwy,选择「全局」及 Skills、全局通用规范或 Hooks。',
42
+ ' 同步全局:执行 dwy,选择「全局」及 Skills、Hooks 或 AGENTS.md 通用规范。',
43
+ ' 移除配置:选择对应范围和 Hooks / 通用规范,再选择「移除」并确认。',
43
44
  ' 更新或调整配置:再次执行 dwy sync,检查勾选项,再确认同步。',
44
45
  ' 更新 CLI:dwy upgrade(全局安装时可用;更新后执行 dwy --version 查看版本)。',
45
46
  ' 迁移老项目:cd your-project 后执行 dwy migrate,无需重新勾选。',
@@ -58,6 +59,12 @@ export function buildHelpText(version) {
58
59
  'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
59
60
  '项目 Skills 全部适用平台均有全局入口时显示「全局已启用」,不能重复勾选。',
60
61
  '部分平台已有全局入口时仍可选,只为缺少全局入口的平台安装项目链接。',
62
+ 'Hooks 与 Skills 独立;整组管理,项目仅安装缺少有效全局注册的 Hooks。',
63
+ '全局 Hook 的脚本与对应事件、matcher、完整命令注册均有效时,项目禁止重复安装。',
64
+ '任一所选 CLI 已有 dwy 全局通用规范时,项目只能继承并清理重复基线;其他 CLI 需补齐全局规范。',
65
+ '手写 AGENTS.md 本身不算全局已安装;通用规范与 Rules 共用文件但独立管理。',
66
+ '移除通用规范仅删除基线块,保留 Rules 和手写内容;移除后不会因默认值自动重新启用。',
67
+ '全局启用会明确列出当前项目重复项清理;全局移除不会自动向项目补装。',
61
68
  '项目同步只清理项目重复入口,不改全局提供者;真实目录先备份。',
62
69
  'sync / migrate 仅准备缺失或损坏副本,不刷新已有共享内容;refresh / upgrade 才刷新。',
63
70
  '产品流程缺失的外部依赖也列入清单,确认后联网补齐,已有内容保留。',
package/src/sync-all.js CHANGED
@@ -6,12 +6,13 @@ import { SEARCH_PLACEHOLDER, searchableMultiselect, searchableSelect } from './s
6
6
  import { normalizeSkillScope, resolveProjectSkillDir, resolveGlobalSkillDir, copySkillsToProjectPlatforms, deduplicateProjectSkills } from './sync-global-skills.js'
7
7
  import { ensureSharedSkills } from './skill-store.js'
8
8
  import { chalk } from './utils.js'
9
- import { interactiveSelect, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
9
+ import { interactiveSelect, loadHookManifests, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
10
10
  import { parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
11
11
  import { scanExistingCursorRules } from './sync-cursor.js'
12
12
  import { installSkills } from './skills-install.js'
13
13
  import { selfUpgrade } from './self-upgrade.js'
14
14
  import { buildSyncPlan, contentOptions, projectSkillChoices, PLATFORM_LABELS, PLATFORM_SYNC_TYPES } from './sync-plan.js'
15
+ import { assertConfigPath, baselineProviders, globalHookNames } from './sync-inheritance.js'
15
16
 
16
17
  const PLATFORM_OPTIONS = [
17
18
  { value: 'claude', label: 'Claude Code', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
@@ -126,7 +127,7 @@ function staleEntries(scans, state, existing, platforms) {
126
127
  const result = {}
127
128
  for (const platform of platforms) {
128
129
  result[platform] = {}
129
- for (const type of PLATFORM_SYNC_TYPES[platform]) {
130
+ for (const type of PLATFORM_SYNC_TYPES[platform].filter(type => current[type])) {
130
131
  result[platform][type] = (state.platforms[platform]?.[type] || []).filter(name =>
131
132
  !current[type].has(name) && existing[platform][type].has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
132
133
  }
@@ -134,6 +135,14 @@ function staleEntries(scans, state, existing, platforms) {
134
135
  return result
135
136
  }
136
137
 
138
+ /** 全局首次默认管理基线;任一所选平台显式移除后不再整组默认开启。 */
139
+ export function defaultContentTypes(scope, platforms, state) {
140
+ const allowed = new Set(contentOptions(scope, platforms).map(option => option.value))
141
+ const result = allowed.has('skills') ? ['skills'] : []
142
+ if (scope === 'global' && allowed.has('baseline') && platforms.every(platform => state.platforms[platform]?.baseline !== false)) result.push('baseline')
143
+ return result
144
+ }
145
+
137
146
  function normalizeRemovals(raw) {
138
147
  return Object.fromEntries(Object.entries(raw).map(([platform, types]) => [platform,
139
148
  Object.fromEntries(Object.entries(types).map(([type, names]) => [type, new Set(names)]))]))
@@ -220,6 +229,7 @@ export async function syncAll({
220
229
  selectedPlatforms: platformsOverride, contentTypes: typesOverride, staleRemovals: removalsOverride,
221
230
  skillScope: skillScopeOverride, syncMode: modeOverride, homeDir = os.homedir(),
222
231
  codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'), scope: scopeOverride, confirmed,
232
+ contentActions: actionsOverride,
223
233
  } = {}) {
224
234
  const sourceDir = sourceDirOverride || await resolveSourceDir()
225
235
  if (!await fs.pathExists(sourceDir)) throw new Error('ai-tools templates not found in repo')
@@ -227,8 +237,8 @@ export async function syncAll({
227
237
  const scope = scopeOverride || (selectedOverride || platformsOverride ? 'project' : await searchableSelect({
228
238
  message: '选择同步范围',
229
239
  options: [
230
- { value: 'project', label: '当前项目', description: '管理项目启用的 Skills、项目 Rules 和 Hooks;全局已有 Skills 自动去重' },
231
- { value: 'global', label: '全局', description: '管理用户级 Skills、通用规范和 Hooks;项目重复 Skill 入口会明确列出清理项' },
240
+ { value: 'project', label: '当前项目', description: '管理 Skills、Hooks、Rules 和通用规范;继承有效全局配置' },
241
+ { value: 'global', label: '全局', description: '管理 Skills、Hooks 和通用规范;清理当前项目重复入口' },
232
242
  ], initialValue: 'project',
233
243
  }))
234
244
  if (isCancel(scope)) { console.log(chalk.yellow('已取消同步。')); return }
@@ -239,13 +249,15 @@ export async function syncAll({
239
249
  if (platforms.length === 0 || platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))) throw new Error('所选平台不支持此同步范围')
240
250
  const options = contentOptions(scope, platforms)
241
251
  const allowed = new Set(options.map(option => option.value))
252
+ const projectState = await loadSyncState(projectDir), globalState = await loadSyncState(homeDir)
253
+ const targetState = global ? globalState : projectState
242
254
  let contentTypes = typesOverride
243
255
  if (!contentTypes && modeOverride === SYNC_MODE_SKILLS) contentTypes = ['skills']
244
256
  if (!contentTypes && selectedOverride) contentTypes = Object.keys(selectedOverride).filter(type => allowed.has(type))
245
257
  if (!contentTypes) {
246
258
  const result = await searchableMultiselect({
247
259
  message: '选择本次处理内容(未勾选的类型保持原样)', options,
248
- initialValues: allowed.has('skills') ? ['skills'] : [], required: true, maxItems: 5,
260
+ initialValues: defaultContentTypes(scope, platforms, targetState), required: true, maxItems: 5,
249
261
  })
250
262
  if (isCancel(result)) { console.log(chalk.yellow('已取消同步。')); return }
251
263
  contentTypes = result
@@ -253,8 +265,6 @@ export async function syncAll({
253
265
  if (contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
254
266
  if (contentTypes.length === 0) { console.log('未选择处理内容。'); return }
255
267
  const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
256
- const projectState = await loadSyncState(projectDir), globalState = await loadSyncState(homeDir)
257
- const targetState = global ? globalState : projectState
258
268
  const existing = await managedNames(targetRoot, global, codexHome)
259
269
  const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type,
260
270
  unionSets(...platforms.map(platform => existing[platform][type]))]))
@@ -266,12 +276,45 @@ export async function syncAll({
266
276
  ? { skills: selectedOverride.skills || [], rules: selectedOverride.rules || [], hooks: [], commands: [] }
267
277
  : await interactiveSelect(choices, defaults, { types: contentTypes })
268
278
  if (selected === null) { console.log(chalk.yellow('已取消同步。')); return }
279
+ const contentActions = { ...actionsOverride }
280
+ if (!selectedOverride) {
281
+ for (const type of ['hooks', 'baseline'].filter(type => contentTypes.includes(type))) {
282
+ if (contentActions[type]) continue
283
+ const inherited = type === 'baseline' && !global ? await baselineProviders(platforms, { homeDir, codexHome }) : []
284
+ const name = type === 'hooks' ? 'Hooks' : 'AGENTS.md 通用规范'
285
+ if (inherited.length) console.log(chalk.yellow(`全局已启用通用规范:${inherited.map(p => PLATFORM_LABELS[p]).join('、')};项目禁止重复安装,其他平台需补齐全局规范。`))
286
+ let inheritedHooks = false
287
+ if (type === 'hooks' && !global) {
288
+ const manifests = await loadHookManifests(sourceDir)
289
+ let total = 0, covered = 0
290
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('hooks'))) {
291
+ const available = scans.hooks.filter(hook => manifests.some(manifest => manifest.name === hook.name && (!manifest.platforms || manifest.platforms.includes(platform))))
292
+ const provided = await globalHookNames({ platform, sourceDir, homeDir, codexHome })
293
+ total += available.length
294
+ covered += available.filter(hook => provided.has(hook.name)).length
295
+ if (provided.size) console.log(`${PLATFORM_LABELS[platform]} Hooks 全局已启用:${[...provided].join('、')}`)
296
+ }
297
+ inheritedHooks = total > 0 && covered === total
298
+ }
299
+ const action = await searchableSelect({
300
+ message: `选择 ${name} 操作`, initialValue: 'sync',
301
+ options: [
302
+ { value: 'sync', label: inherited.length ? '使用全局,仅清理项目重复基线' : inheritedHooks ? '使用全局,仅清理项目重复 Hooks' : '启用 / 更新',
303
+ description: type === 'hooks' && !global ? '只为缺少有效全局注册的 Hook 安装项目配置,其他仅去重' : '执行清单会列出实际路径及清理项' },
304
+ { value: 'remove', label: `移除当前范围的${name}`, description: '只移除 dwy 托管内容,保留个人配置和其他类型' },
305
+ ],
306
+ })
307
+ if (isCancel(action)) { console.log(chalk.yellow('已取消同步。')); return }
308
+ contentActions[type] = action
309
+ }
310
+ }
269
311
  const stale = staleEntries(scans, targetState, existing, platforms)
270
312
  const removals = normalizeRemovals(removalsOverride || stale)
271
313
  const baseline = contentTypes.some(type => type === 'rules' || type === 'baseline') ? await readBaselineDoc(sourceDir) : ''
272
314
  const plan = await buildSyncPlan({
273
315
  scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
274
316
  projectState, globalState, staleRemovals: removals, staleEntries: stale, baseline,
317
+ contentActions,
275
318
  skillScope: normalizeSkillScope(skillScopeOverride),
276
319
  })
277
320
  const nextTarget = global ? plan.nextGlobal : plan.nextProject
@@ -279,16 +322,21 @@ export async function syncAll({
279
322
  nextTarget.syncMode = contentTypes.length === 1 && contentTypes[0] === 'skills' ? SYNC_MODE_SKILLS : SYNC_MODE_ALL
280
323
  nextTarget.skillScope = normalizeSkillScope(skillScopeOverride)
281
324
  const stateFile = path.join(targetRoot, ...SYNC_STATE_PATH)
282
- plan.operations.push({ kind: 'state', label: '更新当前范围同步记录', paths: [stateFile], run: () => saveSyncState(targetRoot, nextTarget) })
325
+ await assertConfigPath(stateFile, targetRoot)
326
+ plan.operations.push({ kind: 'state', label: '更新当前范围同步记录', paths: [stateFile], run: async () => {
327
+ await assertConfigPath(stateFile, targetRoot)
328
+ await saveSyncState(targetRoot, nextTarget)
329
+ } })
283
330
  if (global && plan.projectChanged) plan.operations.push({
284
331
  kind: 'state', label: '更新项目去重记录', paths: [path.join(projectDir, ...SYNC_STATE_PATH)],
285
- run: () => saveSyncState(projectDir, plan.nextProject),
332
+ run: async () => { await assertConfigPath(path.join(projectDir, ...SYNC_STATE_PATH), projectDir); await saveSyncState(projectDir, plan.nextProject) },
286
333
  })
334
+ if (global && plan.projectChanged) await assertConfigPath(path.join(projectDir, ...SYNC_STATE_PATH), projectDir)
287
335
  console.log(`\n同步范围:${global ? '全局' : '当前项目'};平台:${platforms.map(p => PLATFORM_LABELS[p]).join('、')}`)
288
336
  console.log(`处理内容:${contentTypes.map(type => options.find(option => option.value === type).label).join('、')}`)
289
337
  if (contentTypes.includes('skills')) console.log(`Skills:${selected.skills.map(item => item.name).join('、') || '无项目安装选择;已有全局入口仅去重'}`)
290
338
  if (contentTypes.includes('rules')) console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '移除当前项目托管规则'}`)
291
- if (contentTypes.includes('baseline')) console.log(`全局通用规范摘要:${baseline.split('\n').filter(line => /^#/.test(line)).slice(0,3).join(';')}`)
339
+ if (contentTypes.includes('baseline')) console.log(`通用规范:${contentActions.baseline === 'remove' ? '显式移除' : baseline.split('\n').filter(line => /^#/.test(line)).slice(0,3).join(';')}`)
292
340
  for (const operation of plan.operations) {
293
341
  console.log(operation.label)
294
342
  for (const dest of operation.paths) console.log(` → ${dest}`)
package/src/sync-codex.js CHANGED
@@ -111,19 +111,30 @@ function upsertBaselineBlock(content, baselineBody) {
111
111
  return base ? `${block}\n\n${base}` : `${block}\n`
112
112
  }
113
113
 
114
- /** 更新 CLI 全局基线,仅替换 dwy 托管块,并清除旧版写入的项目 Rules 托管块。 */
115
- export async function syncGlobalBaselineDoc(baseline, dest) {
116
- if (!baseline.trim()) return 0
114
+ /** 判断文件是否含有有效的 dwy 通用规范;个人文件不作为继承依据。 */
115
+ export async function hasManagedBaseline(dest) {
116
+ if (!await fs.pathExists(dest)) return false
117
+ const match = (await fs.readFile(dest, 'utf8')).match(BASELINE_RE)
118
+ return Boolean(match && match[0].replace(/<!--[\s\S]*?-->/g, '').trim())
119
+ }
120
+
121
+ /** 独立更新通用规范,空内容移除基线块;保留 Rules 和个人内容。 */
122
+ export async function syncBaselineDoc(baseline, dest) {
117
123
  const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
118
- const previous = current.trim() === baseline.trim() ? '' : current
119
- const next = upsertManagedBlock(upsertBaselineBlock(previous, baseline), '')
124
+ const previous = baseline.trim() && current.trim() === baseline.trim() ? '' : current
125
+ const next = upsertBaselineBlock(previous, baseline)
120
126
  if (next === current) return 0
121
- await fs.ensureDir(path.dirname(dest))
122
- await fs.writeFile(dest, next)
123
- logAction(`global baseline → ${dest}`)
127
+ if (next.trim()) {
128
+ await fs.ensureDir(path.dirname(dest))
129
+ await fs.writeFile(dest, next)
130
+ } else await fs.remove(dest)
131
+ logAction(`baseline → ${dest}`)
124
132
  return 1
125
133
  }
126
134
 
135
+ /** 兼容旧调用方;全局基线更新同样保留其他托管块。 */
136
+ export const syncGlobalBaselineDoc = syncBaselineDoc
137
+
127
138
  /** 迁移旧入口中的基线;无托管标记时只移除与当前模板完全一致的文件。 */
128
139
  export async function removeLegacyBaseline(baseline, dest) {
129
140
  if (!await fs.pathExists(dest)) return
@@ -153,7 +164,7 @@ export async function syncProjectAgentsMd(projectDir, selectedRules, {
153
164
  }
154
165
  const blockBody = sections.join('\n\n---\n\n')
155
166
 
156
- const next = upsertManagedBlock(upsertBaselineBlock(current, ''), blockBody)
167
+ const next = upsertManagedBlock(current, blockBody)
157
168
  if (next === current) return 0
158
169
  await fs.ensureDir(path.dirname(dest))
159
170
  await fs.writeFile(dest, next)
@@ -253,7 +264,8 @@ function globalHookCommand(configDir, name) {
253
264
  return `bash '${script.replace(/'/g, "'\\''")}'`
254
265
  }
255
266
 
256
- function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
267
+ /** 生成 Codex Hook 注册,继承检测和写入共用相同命令格式。 */
268
+ export function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
257
269
  const hooksJson = { hooks: {} }
258
270
  const grouped = new Map()
259
271
 
@@ -284,14 +296,19 @@ function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
284
296
  return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
285
297
  }
286
298
 
287
- function isManagedCodexHookCommand(command, managedHookNames, configDir) {
299
+ /** 只识别完整生成命令及已知历史格式,保留同名个人路径。 */
300
+ export function isManagedCodexHookCommand(command, managedHookNames, configDir) {
288
301
  if (typeof command !== 'string') return false
289
- const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
290
- return !!(match && managedHookNames.has(match[1]))
291
- || [...managedHookNames].some(name => command === globalHookCommand(configDir, name))
302
+ return [...managedHookNames].some(name => [
303
+ globalHookCommand(configDir, name),
304
+ `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${name}"`,
305
+ `bash "$HOME/.codex/hooks/${name}"`,
306
+ `bash ~/.codex/hooks/${name}`,
307
+ ].includes(command))
292
308
  }
293
309
 
294
- function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
310
+ /** 只替换指定托管注册,保留其他命令及配置字段。 */
311
+ export function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
295
312
  const merged = {}
296
313
  const sourceEvents = new Set([
297
314
  ...Object.keys(existingHooks || {}),
@@ -313,14 +330,12 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, config
313
330
  return Object.keys(merged).length > 0 ? merged : undefined
314
331
  }
315
332
 
316
- async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global) {
333
+ async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleHookNames) {
317
334
  const dest = path.join(configDir, 'hooks.json')
318
335
  const manifests = await loadHookManifests(sourceDir)
319
336
  const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
320
337
  const managedHookNames = new Set(
321
- manifests
322
- .filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
323
- .map(manifest => manifest.name),
338
+ [...manifests.map(manifest => manifest.name), ...staleHookNames],
324
339
  )
325
340
 
326
341
  let nextJson
@@ -402,7 +417,7 @@ export async function syncCodex({
402
417
  }
403
418
  if (syncTypes.includes('hooks')) {
404
419
  syncedCount += await copyCodexHooks(syncedSelection.hooks, configDir)
405
- syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global)
420
+ syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleRemovals.hooks || [])
406
421
  }
407
422
 
408
423
  const skillTemplateNames = new Set([
@@ -0,0 +1,119 @@
1
+ import fs from 'fs-extra'
2
+ import path from 'node:path'
3
+ import { buildClaudeHooksSettings, isManagedClaudeHookCommand, mergeClaudeHooks, loadHookManifests, scanHooks } from './sync.js'
4
+ import { buildCodexHooksJson, hasManagedBaseline, isManagedCodexHookCommand, mergeCodexHooks } from './sync-codex.js'
5
+
6
+ /** Hook 配置文件名由平台决定,Codex 根目录由调用方解析。 */
7
+ export function hookConfigFile(platform, root) {
8
+ return path.join(root, platform === 'claude' ? 'settings.json' : 'hooks.json')
9
+ }
10
+
11
+ async function readHookConfig(platform, root) {
12
+ const file = hookConfigFile(platform, root)
13
+ if (!await fs.pathExists(file)) return {}
14
+ const config = await fs.readJson(file)
15
+ if (!config || typeof config !== 'object' || Array.isArray(config)
16
+ || (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))) {
17
+ throw new Error(`Hook 配置格式无效:${file}`)
18
+ }
19
+ for (const configs of Object.values(config.hooks || {})) {
20
+ if (!Array.isArray(configs) || configs.some(cfg => !cfg || typeof cfg !== 'object' || !Array.isArray(cfg.hooks)
21
+ || cfg.hooks.some(hook => !hook || typeof hook !== 'object'))) throw new Error(`Hook 配置格式无效:${file}`)
22
+ }
23
+ return config
24
+ }
25
+
26
+ /** 查找待移除名称的托管注册;不要求脚本仍在,便于清理坏配置。 */
27
+ export async function hasManagedHookConfig(platform, root, names) {
28
+ const config = await readHookConfig(platform, root)
29
+ const matches = platform === 'claude' ? isManagedClaudeHookCommand : isManagedCodexHookCommand
30
+ return Object.values(config.hooks || {}).some(configs => configs.some(cfg => cfg.hooks.some(hook => matches(hook.command, names, root))))
31
+ }
32
+
33
+ /** 不隐式改变用户的全局禁用开关;启用前明确报告冲突。 */
34
+ export async function assertHooksEnabled(platform, root) {
35
+ if ((await readHookConfig(platform, root)).disableAllHooks === true) throw new Error(`Hooks 被 disableAllHooks 禁用,无法启用:${hookConfigFile(platform, root)}`)
36
+ }
37
+
38
+ /** 只移除指定托管脚本和注册,不补装或更新其他 Hooks。 */
39
+ export async function removeManagedHooks(platform, root, names, paths) {
40
+ const file = hookConfigFile(platform, root)
41
+ if (paths.includes(file)) {
42
+ const config = await readHookConfig(platform, root)
43
+ const merge = platform === 'claude' ? mergeClaudeHooks : mergeCodexHooks
44
+ const nextHooks = merge(config.hooks, undefined, names, root)
45
+ const next = { ...config }
46
+ if (nextHooks) next.hooks = nextHooks
47
+ else delete next.hooks
48
+ if (JSON.stringify(config) !== JSON.stringify(next)) {
49
+ if (Object.keys(next).length) await fs.writeJson(file, next, { spaces: 2 })
50
+ else await fs.remove(file)
51
+ }
52
+ }
53
+ for (const dest of paths) if (dest !== file) await fs.remove(dest)
54
+ }
55
+
56
+ /** 解析通用规范的原生全局入口;Cursor 没有文件入口。 */
57
+ export function globalBaselinePath(platform, homeDir, codexHome) {
58
+ return { claude: path.join(homeDir, '.claude', 'CLAUDE.md'), codex: path.join(codexHome, 'AGENTS.md'),
59
+ opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md') }[platform]
60
+ }
61
+
62
+ /** 返回所选 CLI 中实际提供 dwy 全局通用规范的平台。 */
63
+ export async function baselineProviders(platforms, { homeDir, codexHome }) {
64
+ const providers = []
65
+ for (const platform of platforms) {
66
+ const dest = globalBaselinePath(platform, homeDir, codexHome)
67
+ if (dest && await hasManagedBaseline(dest)) providers.push(platform)
68
+ }
69
+ return providers
70
+ }
71
+
72
+ /** 全局 Hook 必须在对应事件及 matcher 下注册了完整执行命令,且脚本仍有效。 */
73
+ export async function globalHookNames({ platform, sourceDir, homeDir, codexHome }) {
74
+ if (!['claude', 'codex'].includes(platform)) return new Set()
75
+ const root = platform === 'claude' ? path.join(homeDir, '.claude') : codexHome
76
+ const config = await readHookConfig(platform, root)
77
+ if (config.disableAllHooks === true) return new Set()
78
+ const manifests = await loadHookManifests(sourceDir)
79
+ const result = new Set()
80
+ for (const hook of await scanHooks(sourceDir)) {
81
+ const names = new Set([hook.name])
82
+ const generated = platform === 'claude' ? buildClaudeHooksSettings(manifests, names, true)
83
+ : buildCodexHooksJson(manifests, names, true, root)
84
+ const registrations = Object.entries(generated.hooks || {}).flatMap(([event, configs]) =>
85
+ configs.flatMap(cfg => cfg.hooks.map(command => ({ event, matcher: cfg.matcher, command: command.command }))))
86
+ if (registrations.length === 0) continue
87
+ const registered = registrations.every(expected => {
88
+ const configs = config.hooks?.[expected.event] || []
89
+ return configs.some(cfg => cfg.matcher === expected.matcher && Array.isArray(cfg.hooks)
90
+ && cfg.hooks.some(item => item.type === 'command' && item.command === expected.command))
91
+ })
92
+ if (!registered) continue
93
+ const stat = await fs.stat(path.join(root, 'hooks', hook.name)).catch(error => {
94
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
95
+ throw error
96
+ })
97
+ if (stat?.isFile()) result.add(hook.name)
98
+ }
99
+ return result
100
+ }
101
+
102
+ /** 配置写入与清理不能穿透边界内的目录或文件链接;确认前及执行时均检查。 */
103
+ export async function assertConfigPath(dest, root) {
104
+ const nearest = async start => {
105
+ let current = start
106
+ while (!await fs.lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error })) current = path.dirname(current)
107
+ return current
108
+ }
109
+ const base = await nearest(root)
110
+ const expectedRoot = path.resolve(await fs.realpath(base), path.relative(base, root))
111
+ const entry = await nearest(dest)
112
+ const expected = path.resolve(expectedRoot, path.relative(root, entry))
113
+ let actual
114
+ try { actual = await fs.realpath(entry) } catch (error) {
115
+ if (!['ENOENT', 'ELOOP', 'ENOTDIR'].includes(error.code)) throw error
116
+ throw new Error(`配置入口为无效链接,保留原入口:${entry}`)
117
+ }
118
+ if (actual !== expected) throw new Error(`配置入口链接指向外部位置,保留原入口:${entry}`)
119
+ }
package/src/sync-plan.js CHANGED
@@ -3,16 +3,17 @@ import path from 'node:path'
3
3
  import { ensureSharedSkills, linkSkill, removeSkillEntry } from './skill-store.js'
4
4
  import { findGlobalSkill, globalSkillRoots, resolveGlobalSkillDir, resolveProjectSkillDir } from './sync-global-skills.js'
5
5
  import { loadHookManifests, syncClaude } from './sync.js'
6
- import { removeLegacyBaseline, syncCodex, syncGlobalBaselineDoc, syncProjectAgentsMd } from './sync-codex.js'
6
+ import { hasManagedBaseline, removeLegacyBaseline, syncBaselineDoc, syncCodex, syncProjectAgentsMd } from './sync-codex.js'
7
7
  import { syncCursor } from './sync-cursor.js'
8
8
  import { planMissingExternalSkills } from './skills-install.js'
9
+ import { assertConfigPath, assertHooksEnabled, baselineProviders, globalBaselinePath, globalHookNames, hasManagedHookConfig, hookConfigFile, removeManagedHooks } from './sync-inheritance.js'
9
10
 
10
11
  /** 平台显示名,交互和变更清单共用。 */
11
12
  export const PLATFORM_LABELS = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', cursor: 'Cursor' }
12
- /** 项目范围的平台能力;全局范围用通用规范替代项目 Rules,且不支持 Cursor。 */
13
+ /** 平台能力;全局不提供 Rules,Cursor 仅提供项目 Rules。 */
13
14
  export const PLATFORM_SYNC_TYPES = {
14
- claude: ['skills', 'rules', 'hooks'], codex: ['skills', 'rules', 'hooks'],
15
- opencode: ['skills', 'rules'], cursor: ['rules'],
15
+ claude: ['skills', 'rules', 'hooks', 'baseline'], codex: ['skills', 'rules', 'hooks', 'baseline'],
16
+ opencode: ['skills', 'rules', 'baseline'], cursor: ['rules'],
16
17
  }
17
18
 
18
19
  /** 只提供所选范围和平台支持的内容;未勾选类型不进入清理或状态更新。 */
@@ -21,8 +22,9 @@ export function contentOptions(scope, platforms) {
21
22
  return [
22
23
  ...(supports('skills') ? [{ value: 'skills', label: 'Skills', description: '管理当前范围的启用入口;已有共享内容不刷新' }] : []),
23
24
  ...(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' }] : []),
25
+ ...(supports('baseline') ? [{ value: 'baseline', label: 'AGENTS.md 通用规范', description: scope === 'global'
26
+ ? '管理原生全局指令入口;首次默认启用,可显式移除' : '管理项目基线块;已有全局规范时仅可清理重复项' }] : []),
27
+ ...(supports('hooks') ? [{ value: 'hooks', label: 'Hooks', description: '独立启用或移除整组;项目继承有效全局注册' }] : []),
26
28
  ]
27
29
  }
28
30
 
@@ -72,6 +74,7 @@ function ownedGlobalRoots(platform, homeDir, codexHome) {
72
74
  export async function buildSyncPlan({
73
75
  scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
74
76
  projectState, globalState, staleRemovals = {}, staleEntries = {}, baseline,
77
+ contentActions = {},
75
78
  skillScope = { destinations: ['project'], globalSkills: [] },
76
79
  }) {
77
80
  const global = scope === 'global'
@@ -85,7 +88,10 @@ export async function buildSyncPlan({
85
88
  if (platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))
86
89
  || contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
87
90
  const active = new Set(contentTypes)
88
- if (active.has('baseline') && !baseline.trim()) throw new Error('没有可同步的全局通用规范')
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('没有可同步的通用规范')
89
95
  const operations = []
90
96
  const nextProject = structuredClone(projectState), nextGlobal = structuredClone(globalState)
91
97
  const targetState = global ? nextGlobal : nextProject
@@ -96,7 +102,18 @@ export async function buildSyncPlan({
96
102
  const addedGlobalPaths = new Set(), removedGlobalPaths = new Set()
97
103
  const localCleanup = new Set()
98
104
  let projectChanged = !global && contentTypes.length > 0
99
- const add = (kind, label, paths, run) => operations.push({ kind, label, paths, run })
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
+ }
100
117
  const platformState = platform => targetState.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
101
118
  const approved = (platform, type) => [...(staleRemovals[platform]?.[type] || [])].filter(safeName)
102
119
  const retained = (platform, type) => (staleEntries[platform]?.[type] || []).filter(name => safeName(name) && !approved(platform, type).includes(name))
@@ -196,7 +213,7 @@ export async function buildSyncPlan({
196
213
  const rules = scans.rules.filter(rule => selectedNames.has(rule.name))
197
214
  const preserved = new Set(cliPlatforms.flatMap(platform => retained(platform, 'rules')))
198
215
  if (cliPlatforms.length) {
199
- add('rules', '更新项目 Rules(保留手写内容)', [path.join(projectDir, 'AGENTS.md')],
216
+ await addConfig('rules', '更新项目 Rules(保留通用规范和手写内容)', [path.join(projectDir, 'AGENTS.md')], projectDir,
200
217
  () => syncProjectAgentsMd(projectDir, rules, { preservedRuleNames: preserved }))
201
218
  const names = [...new Set([...rules.map(rule => rule.name), ...preserved])].sort()
202
219
  for (const platform of ['claude', 'codex', 'opencode']) {
@@ -220,58 +237,110 @@ export async function buildSyncPlan({
220
237
  const dest = path.join(projectDir, '.claude', 'rules', name)
221
238
  if (safeName(name) && await existsEntry(dest)) add('legacy-rule', '清理旧项目规则入口', [dest], () => fs.remove(dest))
222
239
  }
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
240
  }
233
241
  }
234
242
  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 = []
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
+ }
240
254
  }
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))
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, '清理旧项目基线托管内容')
247
285
  }
248
286
  }
249
287
  }
250
288
  }
251
289
  if (active.has('hooks')) {
252
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
+ }
253
303
  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))))
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))
255
307
  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)
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
+ }
266
325
  }
267
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
+ }
268
343
  }
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
344
  }
276
345
  }
277
346
  return { operations, nextProject, nextGlobal, projectChanged, globalChanged: global && contentTypes.length > 0 }
package/src/sync.js CHANGED
@@ -290,7 +290,8 @@ async function removeUnselected(typePlural, existingNames, selectedNames, templa
290
290
  return count
291
291
  }
292
292
 
293
- function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
293
+ /** 生成 Claude Hook 注册,继承检测和写入共用相同命令格式。 */
294
+ export function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
294
295
  const grouped = new Map()
295
296
 
296
297
  for (const manifest of manifests) {
@@ -320,13 +321,18 @@ function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
320
321
  return settings
321
322
  }
322
323
 
323
- function isManagedClaudeHookCommand(command, managedHookNames) {
324
+ /** 仅识别完整的 dwy 生成命令,避免误删提到同名路径的个人命令。 */
325
+ export function isManagedClaudeHookCommand(command, managedHookNames) {
324
326
  if (typeof command !== 'string') return false
325
- const match = command.match(/\.claude\/hooks\/([^\s'"]+)/)
326
- 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))
327
332
  }
328
333
 
329
- function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
334
+ /** 替换指定托管 Hook 注册,保留其他命令及配置字段。 */
335
+ export function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
330
336
  const merged = {}
331
337
  const sourceEvents = new Set([
332
338
  ...Object.keys(existingHooks || {}),
@@ -348,14 +354,12 @@ function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
348
354
  return Object.keys(merged).length > 0 ? merged : undefined
349
355
  }
350
356
 
351
- async function syncSettings(sourceDir, targetDir, selectedHookNames, global) {
357
+ async function syncSettings(sourceDir, targetDir, selectedHookNames, global, staleHookNames) {
352
358
  const settingsDest = path.join(targetDir, 'settings.json')
353
359
  const manifests = await loadHookManifests(sourceDir)
354
360
  const generatedSettings = buildClaudeHooksSettings(manifests, selectedHookNames, global)
355
361
  const managedHookNames = new Set(
356
- manifests
357
- .filter(manifest => !manifest.platforms || manifest.platforms.includes('claude'))
358
- .map(manifest => manifest.name),
362
+ [...manifests.map(manifest => manifest.name), ...staleHookNames],
359
363
  )
360
364
 
361
365
  let merged
@@ -470,7 +474,7 @@ export async function syncClaude({
470
474
  syncedCount += await copyItems(skills, projectTargetDir)
471
475
  }
472
476
  if (syncTypes.includes('hooks')) {
473
- syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global)
477
+ syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global, approvedStaleHooks)
474
478
  syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
475
479
  }
476
480