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 +21 -0
- package/package.json +1 -1
- package/src/cli-help.js +12 -5
- package/src/sync-all.js +58 -10
- package/src/sync-codex.js +35 -20
- package/src/sync-inheritance.js +119 -0
- package/src/sync-plan.js +115 -46
- package/src/sync.js +14 -10
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
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.
|
|
29
|
-
' 项目:Skills
|
|
30
|
-
'
|
|
31
|
-
' 4. 逐项选择 Skills /
|
|
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
|
|
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: '
|
|
231
|
-
{ value: 'global', label: '全局', description: '
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
115
|
-
export async function
|
|
116
|
-
if (!
|
|
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 =
|
|
124
|
+
const previous = baseline.trim() && current.trim() === baseline.trim() ? '' : current
|
|
125
|
+
const next = upsertBaselineBlock(previous, baseline)
|
|
120
126
|
if (next === current) return 0
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
299
|
+
/** 只识别完整生成命令及已知历史格式,保留同名个人路径。 */
|
|
300
|
+
export function isManagedCodexHookCommand(command, managedHookNames, configDir) {
|
|
288
301
|
if (typeof command !== 'string') return false
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
...(
|
|
25
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
/** 仅识别完整的 dwy 生成命令,避免误删提到同名路径的个人命令。 */
|
|
325
|
+
export function isManagedClaudeHookCommand(command, managedHookNames) {
|
|
324
326
|
if (typeof command !== 'string') return false
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
|