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.
- package/CHANGELOG.md +33 -0
- package/package.json +1 -1
- package/src/cli-help.js +33 -16
- package/src/skill-store.js +4 -4
- package/src/sync-all.js +111 -41
- package/src/sync-codex.js +36 -21
- package/src/sync-cursor.js +2 -1
- package/src/sync-inheritance.js +119 -0
- package/src/sync-opencode.js +1 -1
- package/src/sync-plan.js +137 -50
- package/src/sync.js +20 -23
- package/src/template-source.js +218 -0
- package/templates/ai-tools/AGENTS.md +4 -4
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-scope-guard/SKILL.md +91 -0
|
@@ -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-opencode.js
CHANGED
|
@@ -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,
|
|
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
|
|
|
@@ -67,11 +69,13 @@ function ownedGlobalRoots(platform, homeDir, codexHome) {
|
|
|
67
69
|
|
|
68
70
|
/**
|
|
69
71
|
* 只读计算本次全部操作和下一份状态;run 回调只在确认后执行。
|
|
70
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
325
|
+
/** 仅识别完整的 dwy 生成命令,避免误删提到同名路径的个人命令。 */
|
|
326
|
+
export function isManagedClaudeHookCommand(command, managedHookNames) {
|
|
324
327
|
if (typeof command !== 'string') return false
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|