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