create-dwy 0.23.0 → 0.25.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.
@@ -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
+ }
@@ -48,7 +48,7 @@ export async function syncOpenCode({
48
48
  sharedSkills,
49
49
  staleRemovals = {},
50
50
  } = {}) {
51
- const sourceDir = sourceDirOverride || await resolveSourceDir()
51
+ const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
52
52
  if (!await fs.pathExists(sourceDir)) {
53
53
  throw new Error('ai-tools templates not found in repo')
54
54
  }
package/src/sync-plan.js CHANGED
@@ -1,18 +1,19 @@
1
1
  import fs from 'fs-extra'
2
2
  import path from 'node:path'
3
- import { ensureSharedSkills, linkSkill, removeSkillEntry } from './skill-store.js'
3
+ import { ensureSharedSkills, prepareSharedSkills, 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
 
@@ -67,11 +69,13 @@ function ownedGlobalRoots(platform, homeDir, codexHome) {
67
69
 
68
70
  /**
69
71
  * 只读计算本次全部操作和下一份状态;run 回调只在确认后执行。
70
- * Skills 的入口与内容刷新分离;项目去重只删除项目入口,不改写全局提供者。
72
+ * 主交互可在确认后刷新所选共享内容;显式脚本默认仅补缺,项目去重不改写全局提供者。
71
73
  */
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 = {},
78
+ refreshSkills = false,
75
79
  skillScope = { destinations: ['project'], globalSkills: [] },
76
80
  }) {
77
81
  const global = scope === 'global'
@@ -85,7 +89,10 @@ export async function buildSyncPlan({
85
89
  if (platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))
86
90
  || contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
87
91
  const active = new Set(contentTypes)
88
- if (active.has('baseline') && !baseline.trim()) throw new Error('没有可同步的全局通用规范')
92
+ for (const [type, action] of Object.entries(contentActions)) {
93
+ if (!['baseline', 'hooks'].includes(type) || !['sync', 'remove'].includes(action)) throw new Error('无效的内容操作')
94
+ }
95
+ if (active.has('baseline') && contentActions.baseline !== 'remove' && !baseline.trim()) throw new Error('没有可同步的通用规范')
89
96
  const operations = []
90
97
  const nextProject = structuredClone(projectState), nextGlobal = structuredClone(globalState)
91
98
  const targetState = global ? nextGlobal : nextProject
@@ -96,7 +103,18 @@ export async function buildSyncPlan({
96
103
  const addedGlobalPaths = new Set(), removedGlobalPaths = new Set()
97
104
  const localCleanup = new Set()
98
105
  let projectChanged = !global && contentTypes.length > 0
99
- const add = (kind, label, paths, run) => operations.push({ kind, label, paths, run })
106
+ const add = (kind, label, paths, run) => {
107
+ // 无标记旧基线须在 Rules 追加前识别,否则原文会变成无法确认归属的混合内容。
108
+ const beforeRules = ['baseline', 'remove-baseline'].includes(kind) ? operations.findIndex(operation => operation.kind === 'rules') : -1
109
+ operations.splice(beforeRules < 0 ? operations.length : beforeRules, 0, { kind, label, paths, run })
110
+ }
111
+ const addConfig = async (kind, label, paths, boundary, run) => {
112
+ for (const dest of paths) await assertConfigPath(dest, boundary)
113
+ add(kind, label, paths, async () => {
114
+ for (const dest of paths) await assertConfigPath(dest, boundary)
115
+ await run()
116
+ })
117
+ }
100
118
  const platformState = platform => targetState.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
101
119
  const approved = (platform, type) => [...(staleRemovals[platform]?.[type] || [])].filter(safeName)
102
120
  const retained = (platform, type) => (staleEntries[platform]?.[type] || []).filter(name => safeName(name) && !approved(platform, type).includes(name))
@@ -179,9 +197,25 @@ export async function buildSyncPlan({
179
197
  }
180
198
  if (neededSkills.size) {
181
199
  const skills = [...neededSkills.values()]
182
- operations.unshift({ kind: 'ensure-skills', label: '准备缺失的共享副本;已有内容不刷新',
200
+ const preserved = []
201
+ if (refreshSkills && !global) {
202
+ for (let i = 0; i < skills.length; i++) {
203
+ const skill = skills[i], shared = path.join(homeDir, '.dwy', 'skills', skill.name)
204
+ if (!await validSkill(shared)) continue
205
+ for (const platform of ['claude', 'codex', 'opencode']) {
206
+ if (!await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) continue
207
+ // 补项目入口不能间接刷新其他 CLI 正在使用的全局共享副本。
208
+ skills[i] = { ...skill, sourcePath: shared }
209
+ preserved.push(skill.name)
210
+ break
211
+ }
212
+ }
213
+ }
214
+ const label = refreshSkills ? `更新所选共享 Skills(所有同名链接生效)${preserved.length ? `;保留全局共享内容:${preserved.join('、')}` : ''}`
215
+ : '准备缺失的共享副本;已有内容不刷新'
216
+ operations.unshift({ kind: 'ensure-skills', label,
183
217
  paths: [...skills.map(skill => path.join(homeDir, '.dwy', 'skills', skill.name)), path.join(homeDir, '.dwy', 'skill-store.json')],
184
- run: () => ensureSharedSkills({ skills, homeDir }) })
218
+ run: () => (refreshSkills ? prepareSharedSkills : ensureSharedSkills)({ skills, homeDir }) })
185
219
  }
186
220
  if ([...neededSkills.values()].some(skill => skill.category === '产品0到1')) {
187
221
  const target = path.join(process.env.DWY_HOME || path.join(homeDir, '.dwy'), 'skills')
@@ -196,7 +230,7 @@ export async function buildSyncPlan({
196
230
  const rules = scans.rules.filter(rule => selectedNames.has(rule.name))
197
231
  const preserved = new Set(cliPlatforms.flatMap(platform => retained(platform, 'rules')))
198
232
  if (cliPlatforms.length) {
199
- add('rules', '更新项目 Rules(保留手写内容)', [path.join(projectDir, 'AGENTS.md')],
233
+ await addConfig('rules', '更新项目 Rules(保留通用规范和手写内容)', [path.join(projectDir, 'AGENTS.md')], projectDir,
200
234
  () => syncProjectAgentsMd(projectDir, rules, { preservedRuleNames: preserved }))
201
235
  const names = [...new Set([...rules.map(rule => rule.name), ...preserved])].sort()
202
236
  for (const platform of ['claude', 'codex', 'opencode']) {
@@ -220,58 +254,111 @@ export async function buildSyncPlan({
220
254
  const dest = path.join(projectDir, '.claude', 'rules', name)
221
255
  if (safeName(name) && await existsEntry(dest)) add('legacy-rule', '清理旧项目规则入口', [dest], () => fs.remove(dest))
222
256
  }
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
257
  }
233
258
  }
234
259
  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 = []
260
+ const remove = contentActions.baseline === 'remove'
261
+ const providers = global ? [] : await baselineProviders(cliPlatforms, { homeDir, codexHome })
262
+ const inherited = providers.length > 0
263
+ const markProject = enabled => {
264
+ for (const platform of ['claude', 'codex', 'opencode']) {
265
+ if ((!global && platforms.includes(platform)) || nextProject.platforms[platform]) {
266
+ nextProject.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
267
+ nextProject.platforms[platform].baseline = enabled
268
+ if (global) projectChanged = true
269
+ }
270
+ }
240
271
  }
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))
272
+ const cleanBaseline = async (dest, boundary, label) => {
273
+ if (!await fs.pathExists(dest)) return false
274
+ const text = await fs.readFile(dest, 'utf8')
275
+ if (!await hasManagedBaseline(dest) && (!baseline.trim() || text.trim() !== baseline.trim())) return false
276
+ await addConfig('remove-baseline', label, [dest], boundary, () => text.trim() === baseline.trim()
277
+ ? removeLegacyBaseline(baseline, dest) : syncBaselineDoc('', dest))
278
+ return true
279
+ }
280
+ if (global) {
281
+ for (const platform of cliPlatforms) {
282
+ const dest = globalBaselinePath(platform, homeDir, codexHome)
283
+ if (remove) await cleanBaseline(dest, platform === 'codex' ? codexHome : homeDir, `移除 ${PLATFORM_LABELS[platform]} 全局通用规范(保留 Rules 和手写内容)`)
284
+ else await addConfig('baseline', `启用/更新 ${PLATFORM_LABELS[platform]} 全局通用规范(保留其他内容)`, [dest], platform === 'codex' ? codexHome : homeDir, () => syncBaselineDoc(baseline, dest))
285
+ platformState(platform).baseline = !remove
286
+ }
287
+ if (platforms.includes('claude')) await cleanBaseline(path.join(homeDir, '.claude', 'AGENTS.md'), homeDir, '清理旧全局基线托管内容')
288
+ } else {
289
+ const dest = path.join(projectDir, 'AGENTS.md')
290
+ if (remove || inherited) {
291
+ await cleanBaseline(dest, projectDir, inherited ? '全局已启用,清理项目重复基线' : '移除项目通用规范(保留 Rules 和手写内容)')
292
+ const missing = cliPlatforms.filter(platform => !providers.includes(platform))
293
+ if (inherited) add('inherit', `通用规范使用全局:${providers.map(p => PLATFORM_LABELS[p]).join('、')};项目禁止重复安装${missing.length ? `;${missing.map(p => PLATFORM_LABELS[p]).join('、')} 缺少全局规范,请另行选择全局范围补齐` : ''}`,
294
+ providers.map(p => globalBaselinePath(p, homeDir, codexHome)), async () => {})
295
+ } else await addConfig('baseline', '启用/更新项目 AGENTS.md 通用规范(保留 Rules 和手写内容)', [dest], projectDir, () => syncBaselineDoc(baseline, dest))
296
+ markProject(!remove && !inherited)
297
+ }
298
+ if ((!global || !remove) && !isHome) {
299
+ if (global && await cleanBaseline(path.join(projectDir, 'AGENTS.md'), projectDir, '全局优先,清理当前项目重复基线')) markProject(false)
300
+ if (platforms.includes('claude')) {
301
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
302
+ await cleanBaseline(path.join(projectDir, '.claude', name), projectDir, '清理旧项目基线托管内容')
247
303
  }
248
304
  }
249
305
  }
250
306
  }
251
307
  if (active.has('hooks')) {
252
308
  const manifests = await loadHookManifests(sourceDir)
309
+ const remove = contentActions.hooks === 'remove'
310
+ const removeHooks = async (platform, root, boundary, names, label) => {
311
+ const paths = []
312
+ for (const name of names) {
313
+ const dest = path.join(root, 'hooks', name)
314
+ if (await existsEntry(dest)) paths.push(dest)
315
+ }
316
+ if (await hasManagedHookConfig(platform, root, names)) paths.push(hookConfigFile(platform, root))
317
+ if (!paths.length) return false
318
+ await addConfig('remove-hooks', label, paths, boundary, () => removeManagedHooks(platform, root, names, paths))
319
+ return true
320
+ }
253
321
  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))))
322
+ const available = scans.hooks.filter(hook => manifests.some(manifest => manifest.name === hook.name && (!manifest.platforms || manifest.platforms.includes(platform))))
323
+ const provided = !global && !remove ? await globalHookNames({ platform, sourceDir, homeDir, codexHome }) : new Set()
324
+ const hooks = remove ? [] : available.filter(hook => !provided.has(hook.name))
255
325
  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)
326
+ const boundary = global && platform === 'codex' ? codexHome : targetRoot
327
+ const historical = (targetState.platforms[platform]?.hooks || []).filter(safeName)
328
+ const allManaged = new Set([...scans.hooks.map(hook => hook.name), ...(remove ? historical : []), ...approved(platform, 'hooks')])
329
+ if (remove || hooks.length === 0) {
330
+ await removeHooks(platform, root, boundary, allManaged, `${remove ? '移除' : '全局已启用,清理重复'} ${PLATFORM_LABELS[platform]} ${global ? '全局' : '项目'} 托管 Hooks`)
331
+ } else {
332
+ await assertHooksEnabled(platform, root)
333
+ const paths = hooks.map(hook => path.join(root, 'hooks', hook.name))
334
+ const selectedNames = new Set(hooks.map(hook => hook.name))
335
+ const removedNames = []
336
+ const hookDir = path.join(root, 'hooks')
337
+ if (await fs.pathExists(hookDir)) {
338
+ for (const name of await fs.readdir(hookDir)) {
339
+ if (allManaged.has(name) && !selectedNames.has(name)) {
340
+ paths.push(path.join(hookDir, name))
341
+ removedNames.push(name)
342
+ }
266
343
  }
267
344
  }
345
+ paths.push(hookConfigFile(platform, root))
346
+ const sync = platform === 'claude' ? syncClaude : syncCodex
347
+ const cleanup = removedNames.length ? `;清理旧托管脚本:${removedNames.join('、')}` : ''
348
+ await addConfig('hooks', `同步 ${PLATFORM_LABELS[platform]} ${global ? '全局' : '项目'} Hooks${cleanup}`, paths, boundary,
349
+ () => sync({ sourceDir, projectDir: targetRoot, global, homeDir, codexHome, selected: { skills: [], hooks }, syncTypes: ['hooks'], staleRemovals: staleRemovals[platform] }))
350
+ }
351
+ if (provided.size) add('inherit', `${PLATFORM_LABELS[platform]} Hooks 使用全局:${[...provided].join('、')};项目不重复安装`, [], async () => {})
352
+ platformState(platform).hooks = remove ? [] : [...new Set([...hooks.map(hook => hook.name), ...retained(platform, 'hooks')])].sort()
353
+ if (global && !remove && !isHome) {
354
+ const local = path.join(projectDir, platform === 'claude' ? '.claude' : '.codex')
355
+ const duplicates = new Set(available.map(hook => hook.name))
356
+ if (await removeHooks(platform, local, projectDir, duplicates, `全局优先,清理当前项目 ${PLATFORM_LABELS[platform]} 重复 Hooks`)
357
+ && nextProject.platforms[platform]) {
358
+ nextProject.platforms[platform].hooks = (nextProject.platforms[platform].hooks || []).filter(name => !duplicates.has(name))
359
+ projectChanged = true
360
+ }
268
361
  }
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
362
  }
276
363
  }
277
364
  return { operations, nextProject, nextGlobal, projectChanged, globalChanged: global && contentTypes.length > 0 }
package/src/sync.js CHANGED
@@ -2,8 +2,9 @@ import fs from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { isCancel } from '@clack/prompts'
4
4
  import { tabMultiselect } from './tab-multiselect.js'
5
- import { chalk, PACKAGE_ROOT } from './utils.js'
5
+ import { chalk } from './utils.js'
6
6
  import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
7
+ import { resolveTemplateSource } from './template-source.js'
7
8
 
8
9
  const CATEGORIES = [
9
10
  { key: 'skills', label: 'Skills' },
@@ -290,7 +291,8 @@ async function removeUnselected(typePlural, existingNames, selectedNames, templa
290
291
  return count
291
292
  }
292
293
 
293
- function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
294
+ /** 生成 Claude Hook 注册,继承检测和写入共用相同命令格式。 */
295
+ export function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
294
296
  const grouped = new Map()
295
297
 
296
298
  for (const manifest of manifests) {
@@ -320,13 +322,18 @@ function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
320
322
  return settings
321
323
  }
322
324
 
323
- function isManagedClaudeHookCommand(command, managedHookNames) {
325
+ /** 仅识别完整的 dwy 生成命令,避免误删提到同名路径的个人命令。 */
326
+ export function isManagedClaudeHookCommand(command, managedHookNames) {
324
327
  if (typeof command !== 'string') return false
325
- const match = command.match(/\.claude\/hooks\/([^\s'"]+)/)
326
- return !!(match && managedHookNames.has(match[1]))
328
+ return [...managedHookNames].some(name => [
329
+ `bash "$HOME/.claude/hooks/${name}"`,
330
+ `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}"`,
331
+ `bash ~/.claude/hooks/${name}`,
332
+ ].includes(command))
327
333
  }
328
334
 
329
- function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
335
+ /** 替换指定托管 Hook 注册,保留其他命令及配置字段。 */
336
+ export function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
330
337
  const merged = {}
331
338
  const sourceEvents = new Set([
332
339
  ...Object.keys(existingHooks || {}),
@@ -348,14 +355,12 @@ function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
348
355
  return Object.keys(merged).length > 0 ? merged : undefined
349
356
  }
350
357
 
351
- async function syncSettings(sourceDir, targetDir, selectedHookNames, global) {
358
+ async function syncSettings(sourceDir, targetDir, selectedHookNames, global, staleHookNames) {
352
359
  const settingsDest = path.join(targetDir, 'settings.json')
353
360
  const manifests = await loadHookManifests(sourceDir)
354
361
  const generatedSettings = buildClaudeHooksSettings(manifests, selectedHookNames, global)
355
362
  const managedHookNames = new Set(
356
- manifests
357
- .filter(manifest => !manifest.platforms || manifest.platforms.includes('claude'))
358
- .map(manifest => manifest.name),
363
+ [...manifests.map(manifest => manifest.name), ...staleHookNames],
359
364
  )
360
365
 
361
366
  let merged
@@ -386,17 +391,9 @@ async function syncSettings(sourceDir, targetDir, selectedHookNames, global) {
386
391
  return 1
387
392
  }
388
393
 
389
- /** 默认读取随包模板;仅开发者显式设置环境变量时改用外部目录,不要求仓库权限。 */
390
- export async function resolveSourceDir() {
391
- const explicitSourceDir = process.env.DWY_TEMPLATE_SOURCE_DIR
392
- if (explicitSourceDir) {
393
- if (!await fs.pathExists(explicitSourceDir)) {
394
- throw new Error(`DWY_TEMPLATE_SOURCE_DIR not found: ${explicitSourceDir}`)
395
- }
396
- return explicitSourceDir
397
- }
398
-
399
- return path.join(PACKAGE_ROOT, 'templates', 'ai-tools')
394
+ /** 解析模板源并检查公开模板更新;调用方可注入 homeDir 隔离用户缓存。 */
395
+ export async function resolveSourceDir(options) {
396
+ return resolveTemplateSource(options)
400
397
  }
401
398
 
402
399
  /** 同步 Claude Skills 与 Hooks;基线和项目 Rules 由总同步入口分别处理。 */
@@ -410,7 +407,7 @@ export async function syncClaude({
410
407
  syncTypes = ['skills', 'rules', 'hooks'],
411
408
  staleRemovals = {},
412
409
  } = {}) {
413
- const sourceDir = sourceDirOverride || await resolveSourceDir()
410
+ const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
414
411
  if (!await fs.pathExists(sourceDir)) {
415
412
  throw new Error('ai-tools templates not found in repo')
416
413
  }
@@ -470,7 +467,7 @@ export async function syncClaude({
470
467
  syncedCount += await copyItems(skills, projectTargetDir)
471
468
  }
472
469
  if (syncTypes.includes('hooks')) {
473
- syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global)
470
+ syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global, approvedStaleHooks)
474
471
  syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
475
472
  }
476
473