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-all.js
CHANGED
|
@@ -1,94 +1,44 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
|
-
import path from 'path'
|
|
2
|
+
import path from 'node:path'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import { confirm, isCancel } from '@clack/prompts'
|
|
5
5
|
import { SEARCH_PLACEHOLDER, searchableMultiselect, searchableSelect } from './searchable-select.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
copySkillsToProjectPlatforms,
|
|
9
|
-
normalizeSkillScope,
|
|
10
|
-
resolveProjectSkillDir,
|
|
11
|
-
resolveGlobalSkillDir,
|
|
12
|
-
deduplicateProjectSkills,
|
|
13
|
-
findGlobalSkill,
|
|
14
|
-
scanGlobalSkills,
|
|
15
|
-
} from './sync-global-skills.js'
|
|
16
|
-
import { prepareSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
6
|
+
import { normalizeSkillScope, resolveProjectSkillDir, resolveGlobalSkillDir, copySkillsToProjectPlatforms, deduplicateProjectSkills } from './sync-global-skills.js'
|
|
7
|
+
import { ensureSharedSkills } from './skill-store.js'
|
|
17
8
|
import { chalk } from './utils.js'
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
resolveSourceDir,
|
|
23
|
-
scanExisting,
|
|
24
|
-
scanHooks,
|
|
25
|
-
scanRules,
|
|
26
|
-
scanSkills,
|
|
27
|
-
syncClaude,
|
|
28
|
-
} from './sync.js'
|
|
29
|
-
import {
|
|
30
|
-
parseManagedRuleNames,
|
|
31
|
-
removeLegacyBaseline,
|
|
32
|
-
scanExistingCodexHooks,
|
|
33
|
-
scanExistingCodexSkills,
|
|
34
|
-
syncCodex,
|
|
35
|
-
syncGlobalBaselineDoc,
|
|
36
|
-
syncProjectAgentsMd,
|
|
37
|
-
} from './sync-codex.js'
|
|
38
|
-
import { scanExistingCursorRules, syncCursor } from './sync-cursor.js'
|
|
39
|
-
import { syncOpenCode } from './sync-opencode.js'
|
|
40
|
-
import { ensureSkillsInstalled, installSkills } from './skills-install.js'
|
|
9
|
+
import { interactiveSelect, loadHookManifests, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
|
|
10
|
+
import { parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
|
|
11
|
+
import { scanExistingCursorRules } from './sync-cursor.js'
|
|
12
|
+
import { installSkills } from './skills-install.js'
|
|
41
13
|
import { selfUpgrade } from './self-upgrade.js'
|
|
14
|
+
import { buildSyncPlan, contentOptions, projectSkillChoices, PLATFORM_LABELS, PLATFORM_SYNC_TYPES } from './sync-plan.js'
|
|
15
|
+
import { assertConfigPath, baselineProviders, globalHookNames } from './sync-inheritance.js'
|
|
42
16
|
|
|
43
17
|
const PLATFORM_OPTIONS = [
|
|
44
|
-
{ value: 'claude', label: 'Claude Code', description: '支持
|
|
45
|
-
{ value: 'codex', label: 'Codex', description: '支持
|
|
46
|
-
{ value: '
|
|
47
|
-
{ value: '
|
|
18
|
+
{ value: 'claude', label: 'Claude Code', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
|
|
19
|
+
{ value: 'codex', label: 'Codex', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
|
|
20
|
+
{ value: 'opencode', label: 'OpenCode', description: '支持 Skills、项目 Rules 和全局通用规范;不支持 Hooks' },
|
|
21
|
+
{ value: 'cursor', label: 'Cursor', description: '只支持项目 Rules' },
|
|
48
22
|
]
|
|
49
|
-
|
|
50
23
|
const SYNC_STATE_PATH = ['.dwy', 'sync-state.json']
|
|
51
|
-
|
|
52
|
-
claude: ['skills', 'rules', 'hooks'],
|
|
53
|
-
codex: ['skills', 'rules', 'hooks'],
|
|
54
|
-
cursor: ['rules'],
|
|
55
|
-
opencode: ['skills', 'rules'],
|
|
56
|
-
}
|
|
57
|
-
const PLATFORM_LABELS = {
|
|
58
|
-
claude: 'Claude Code',
|
|
59
|
-
codex: 'Codex',
|
|
60
|
-
cursor: 'Cursor',
|
|
61
|
-
opencode: 'OpenCode',
|
|
62
|
-
}
|
|
63
|
-
/** 完整同步:逐项选择 skills / rules,hooks 自动启用。 */
|
|
24
|
+
/** 按显式选中的内容同步;保留已有脚本的模式标识。 */
|
|
64
25
|
export const SYNC_MODE_ALL = 'all'
|
|
65
|
-
/**
|
|
26
|
+
/** 兼容已有脚本的仅 Skills 模式。 */
|
|
66
27
|
export const SYNC_MODE_SKILLS = 'skills'
|
|
67
28
|
export const DEFAULT_SYNC_MODE = SYNC_MODE_ALL
|
|
68
|
-
|
|
69
|
-
/** 同步项目配置(skills / rules / hooks) */
|
|
29
|
+
/** 交互式配置同步。 */
|
|
70
30
|
export const ACTION_SYNC = 'sync'
|
|
71
|
-
/**
|
|
31
|
+
/** 安装产品流程依赖的外部 Skills;仅供已有脚本调用。 */
|
|
72
32
|
export const ACTION_INSTALL_SKILLS = 'install-skills'
|
|
73
|
-
/**
|
|
33
|
+
/** 升级全局 CLI。 */
|
|
74
34
|
export const ACTION_UPGRADE = 'upgrade'
|
|
75
35
|
|
|
76
|
-
/**
|
|
77
|
-
* 规范化同步范围。未知值回退完整同步,避免误进仅 Skills 删不该删的东西。
|
|
78
|
-
*
|
|
79
|
-
* @param {unknown} raw
|
|
80
|
-
* @returns {'all' | 'skills'}
|
|
81
|
-
*/
|
|
36
|
+
/** 兼容已有脚本的模式值,不从模式推导交互中的隐式写入。 */
|
|
82
37
|
export function normalizeSyncMode(raw) {
|
|
83
38
|
return raw === SYNC_MODE_SKILLS ? SYNC_MODE_SKILLS : DEFAULT_SYNC_MODE
|
|
84
39
|
}
|
|
85
40
|
|
|
86
|
-
/**
|
|
87
|
-
* 规范化入口动作。未知值回退同步,避免误进强制重装。
|
|
88
|
-
*
|
|
89
|
-
* @param {unknown} raw
|
|
90
|
-
* @returns {'sync' | 'install-skills' | 'upgrade'}
|
|
91
|
-
*/
|
|
41
|
+
/** 兼容已有脚本的入口动作;未知动作进入正常交互。 */
|
|
92
42
|
export function normalizeAction(raw) {
|
|
93
43
|
if (raw === ACTION_INSTALL_SKILLS) return ACTION_INSTALL_SKILLS
|
|
94
44
|
if (raw === ACTION_UPGRADE) return ACTION_UPGRADE
|
|
@@ -99,334 +49,115 @@ function unionSets(...sets) {
|
|
|
99
49
|
return new Set(sets.flatMap(set => [...set]))
|
|
100
50
|
}
|
|
101
51
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
skills: new Set(),
|
|
109
|
-
rules: new Set(),
|
|
110
|
-
commands: new Set(),
|
|
111
|
-
hooks: new Set(),
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
for (const platformState of Object.values(syncState?.platforms || {})) {
|
|
115
|
-
for (const type of ['skills', 'rules', 'commands', 'hooks']) {
|
|
116
|
-
for (const name of platformState?.[type] || []) {
|
|
117
|
-
stateDefaults[type].add(name)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return hasSetItems(stateDefaults) ? stateDefaults : existing
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function scanCombinedExisting(projectDir, global = false) {
|
|
126
|
-
const claudeDir = path.join(projectDir, '.claude')
|
|
127
|
-
const agentsMdPath = path.join(projectDir, 'AGENTS.md')
|
|
128
|
-
const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
skills: unionSets(
|
|
132
|
-
await scanExisting(claudeDir, 'skills'),
|
|
133
|
-
await scanExistingCodexSkills(projectDir),
|
|
134
|
-
await scanExisting(path.join(projectDir, ...(global ? ['.config', 'opencode'] : ['.opencode'])), 'skills'),
|
|
135
|
-
),
|
|
136
|
-
rules: unionSets(
|
|
137
|
-
await scanExisting(claudeDir, 'rules'),
|
|
138
|
-
parseManagedRuleNames(agentsMdContent),
|
|
139
|
-
),
|
|
140
|
-
hooks: unionSets(
|
|
141
|
-
await scanExisting(claudeDir, 'hooks'),
|
|
142
|
-
await scanExistingCodexHooks(projectDir),
|
|
143
|
-
),
|
|
52
|
+
/** 从点名的平台记录恢复选择;未传平台时保留历史调用约定。 */
|
|
53
|
+
export function buildSelectionDefaultsFromSyncState(existing, syncState, platforms = Object.keys(syncState?.platforms || {})) {
|
|
54
|
+
const result = { skills: new Set(), rules: new Set(), commands: new Set(), hooks: new Set() }
|
|
55
|
+
for (const platform of platforms) {
|
|
56
|
+
const state = syncState?.platforms?.[platform] || {}
|
|
57
|
+
for (const type of Object.keys(result)) for (const name of state[type] || []) result[type].add(name)
|
|
144
58
|
}
|
|
59
|
+
return Object.values(result).some(names => names.size) ? result : existing
|
|
145
60
|
}
|
|
146
61
|
|
|
147
|
-
|
|
62
|
+
/** 从当前范围的实际入口推导平台默认值;不读取另一个范围的配置。 */
|
|
63
|
+
export async function buildPlatformDefaultsFromLocalState(projectDir, scope = 'project', codexHome = path.join(projectDir, '.codex')) {
|
|
148
64
|
const defaults = []
|
|
149
|
-
|
|
150
|
-
if (await fs.pathExists(path.join(projectDir, '.
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
if (
|
|
154
|
-
await fs.pathExists(path.join(projectDir, '.codex'))
|
|
65
|
+
if (await fs.pathExists(path.join(projectDir, '.claude'))) defaults.push('claude')
|
|
66
|
+
if (await fs.pathExists(path.join(projectDir, '.codex'))
|
|
67
|
+
|| (scope === 'global' && await fs.pathExists(codexHome))
|
|
155
68
|
|| await fs.pathExists(path.join(projectDir, '.agents'))
|
|
156
|
-
|| await fs.pathExists(path.join(projectDir, 'AGENTS.md'))
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (await fs.pathExists(path.join(projectDir, '.cursor'))) {
|
|
161
|
-
defaults.push('cursor')
|
|
162
|
-
}
|
|
163
|
-
if (await fs.pathExists(path.join(projectDir, '.opencode'))) {
|
|
164
|
-
defaults.push('opencode')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return defaults.length > 0 ? defaults : ['claude', 'codex']
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* 仅 Skills 模式:只写项目/全局 skill,并清理未选中的模板 skill。
|
|
172
|
-
* 禁止调用完整平台 sync,否则空 rules/hooks 会删掉项目已有项。
|
|
173
|
-
*
|
|
174
|
-
* @param {{ projectDir: string, selectedPlatforms: string[], skills: Array<{ name: string, sourcePath: string }>, templateSkillNames: Set<string>, staleSkillNames: Set<string>, skillScope: { destinations: string[] } }} opts
|
|
175
|
-
*/
|
|
176
|
-
async function syncSkillsOnly({
|
|
177
|
-
projectDir,
|
|
178
|
-
selectedPlatforms,
|
|
179
|
-
skills,
|
|
180
|
-
templateSkillNames,
|
|
181
|
-
staleSkillNames,
|
|
182
|
-
skillScope,
|
|
183
|
-
homeDir,
|
|
184
|
-
global,
|
|
185
|
-
duplicateNames,
|
|
186
|
-
}) {
|
|
187
|
-
const writeProject = skillScope.destinations.includes('project')
|
|
188
|
-
if (!writeProject) return
|
|
189
|
-
|
|
190
|
-
const skillPlatforms = selectedPlatforms.filter(platform => resolveProjectSkillDir(projectDir, platform))
|
|
191
|
-
if (skillPlatforms.length === 0) {
|
|
192
|
-
console.log(chalk.yellow('已选平台不支持项目内 skills,跳过项目写入。'))
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
console.log(chalk.blue('\nSyncing skills only (rules / commands / hooks untouched)...\n'))
|
|
197
|
-
for (const platform of skillPlatforms) {
|
|
198
|
-
const platformSkills = skills.filter(skill => !duplicateNames[platform]?.has(skill.name))
|
|
199
|
-
if (global) {
|
|
200
|
-
await copySkillsToGlobalDirs({ skills: platformSkills, destIds: [platform === 'codex' ? 'agents' : platform], homeDir })
|
|
201
|
-
} else {
|
|
202
|
-
await copySkillsToProjectPlatforms({ projectDir, platforms: [platform], skills: platformSkills, homeDir })
|
|
203
|
-
}
|
|
204
|
-
const selectedNames = new Set(platformSkills.map(item => item.name))
|
|
205
|
-
const toRemove = new Set([...templateSkillNames, ...staleSkillNames].filter(name => !selectedNames.has(name)))
|
|
206
|
-
const destRoot = global
|
|
207
|
-
? resolveGlobalSkillDir(platform === 'codex' ? 'agents' : platform, homeDir)
|
|
208
|
-
: resolveProjectSkillDir(projectDir, platform)
|
|
209
|
-
const existingNames = await scanExisting(path.dirname(destRoot), 'skills')
|
|
210
|
-
await removeManagedItems(
|
|
211
|
-
destRoot,
|
|
212
|
-
existingNames,
|
|
213
|
-
toRemove,
|
|
214
|
-
path.relative(projectDir, destRoot),
|
|
215
|
-
path.join(global ? homeDir : projectDir, '.dwy', 'skill-backups'),
|
|
216
|
-
)
|
|
217
|
-
}
|
|
69
|
+
|| (scope === 'project' && await fs.pathExists(path.join(projectDir, 'AGENTS.md')))) defaults.push('codex')
|
|
70
|
+
if (scope === 'project' && await fs.pathExists(path.join(projectDir, '.cursor'))) defaults.push('cursor')
|
|
71
|
+
if (await fs.pathExists(path.join(projectDir, ...(scope === 'global' ? ['.config', 'opencode'] : ['.opencode'])))) defaults.push('opencode')
|
|
72
|
+
return defaults.length ? defaults : ['claude', 'codex']
|
|
218
73
|
}
|
|
219
74
|
|
|
220
|
-
async function selectPlatforms(
|
|
221
|
-
const initialValues = await buildPlatformDefaultsFromLocalState(projectDir)
|
|
222
|
-
|
|
223
|
-
// 可搜索多选 + 底部说明区
|
|
75
|
+
async function selectPlatforms(root, scope, codexHome) {
|
|
224
76
|
const result = await searchableMultiselect({
|
|
225
77
|
message: '选择要同步的平台',
|
|
226
|
-
options: PLATFORM_OPTIONS
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
maxItems: 6,
|
|
230
|
-
placeholder: SEARCH_PLACEHOLDER,
|
|
78
|
+
options: PLATFORM_OPTIONS.filter(option => scope === 'project' || option.value !== 'cursor')
|
|
79
|
+
.map(option => ({ ...option, description: contentOptions(scope, [option.value]).map(item => item.label).join('、') })),
|
|
80
|
+
initialValues: await buildPlatformDefaultsFromLocalState(root, scope, codexHome),
|
|
81
|
+
required: true, maxItems: 6, placeholder: SEARCH_PLACEHOLDER,
|
|
231
82
|
})
|
|
232
|
-
|
|
233
|
-
return result
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function buildCursorRuleName(ruleName) {
|
|
237
|
-
return `dwy-${ruleName.replace(/\.md$/i, '')}.mdc`
|
|
83
|
+
return isCancel(result) ? null : result
|
|
238
84
|
}
|
|
239
85
|
|
|
240
86
|
function createEmptyPlatformState() {
|
|
241
|
-
return {
|
|
242
|
-
skills: [],
|
|
243
|
-
rules: [],
|
|
244
|
-
commands: [],
|
|
245
|
-
hooks: [],
|
|
246
|
-
}
|
|
87
|
+
return { skills: [], rules: [], commands: [], hooks: [] }
|
|
247
88
|
}
|
|
248
89
|
|
|
249
|
-
async function loadSyncState(
|
|
250
|
-
const
|
|
251
|
-
if (!await fs.pathExists(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
platforms: {},
|
|
255
|
-
skillScope: normalizeSkillScope(undefined),
|
|
256
|
-
syncMode: DEFAULT_SYNC_MODE,
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const state = await fs.readJson(statePath)
|
|
261
|
-
return {
|
|
262
|
-
version: 1,
|
|
263
|
-
platforms: state.platforms || {},
|
|
264
|
-
skillScope: normalizeSkillScope(state.skillScope),
|
|
265
|
-
syncMode: normalizeSyncMode(state.syncMode),
|
|
266
|
-
}
|
|
90
|
+
async function loadSyncState(root) {
|
|
91
|
+
const file = path.join(root, ...SYNC_STATE_PATH)
|
|
92
|
+
if (!await fs.pathExists(file)) return { version: 1, platforms: {}, skillScope: normalizeSkillScope(undefined), syncMode: DEFAULT_SYNC_MODE }
|
|
93
|
+
const state = await fs.readJson(file)
|
|
94
|
+
return { version: 1, platforms: state.platforms || {}, skillScope: normalizeSkillScope(state.skillScope), syncMode: normalizeSyncMode(state.syncMode) }
|
|
267
95
|
}
|
|
268
96
|
|
|
269
|
-
async function saveSyncState(
|
|
270
|
-
const
|
|
271
|
-
await fs.ensureDir(path.dirname(
|
|
272
|
-
await fs.writeJson(
|
|
97
|
+
async function saveSyncState(root, state) {
|
|
98
|
+
const file = path.join(root, ...SYNC_STATE_PATH)
|
|
99
|
+
await fs.ensureDir(path.dirname(file))
|
|
100
|
+
await fs.writeJson(file, state, { spaces: 2 })
|
|
273
101
|
}
|
|
274
102
|
|
|
275
|
-
function
|
|
276
|
-
return {
|
|
277
|
-
skills: new Set(scans.skills.map(item => item.name)),
|
|
278
|
-
rules: new Set(scans.rules.map(item => item.name)),
|
|
279
|
-
commands: new Set(scans.commands.map(item => item.name)),
|
|
280
|
-
hooks: new Set(scans.hooks.map(item => item.name)),
|
|
281
|
-
}
|
|
103
|
+
function buildCursorRuleName(name) {
|
|
104
|
+
return `dwy-${name.replace(/\.md$/i, '')}.mdc`
|
|
282
105
|
}
|
|
283
106
|
|
|
284
|
-
async function
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
rules: unionSets(await scanExisting(claudeDir, 'rules'), parseManagedRuleNames(agentsMdContent)),
|
|
294
|
-
hooks: await scanExisting(claudeDir, 'hooks'),
|
|
295
|
-
},
|
|
296
|
-
codex: {
|
|
297
|
-
skills: await scanExistingCodexSkills(projectDir),
|
|
298
|
-
rules: parseManagedRuleNames(agentsMdContent),
|
|
299
|
-
hooks: await scanExistingCodexHooks(projectDir),
|
|
300
|
-
},
|
|
301
|
-
cursor: {
|
|
302
|
-
rules: await scanExistingCursorRules(projectDir),
|
|
303
|
-
},
|
|
304
|
-
opencode: {
|
|
305
|
-
skills: await scanExisting(openCodeDir, 'skills'),
|
|
306
|
-
rules: global ? parseManagedRuleNames(await fs.pathExists(path.join(openCodeDir, 'AGENTS.md')) ? await fs.readFile(path.join(openCodeDir, 'AGENTS.md'), 'utf-8') : '') : parseManagedRuleNames(agentsMdContent),
|
|
307
|
-
},
|
|
107
|
+
async function managedNames(root, global, codexHome) {
|
|
108
|
+
const projectFile = path.join(root, 'AGENTS.md')
|
|
109
|
+
const projectRules = !global && await fs.pathExists(projectFile)
|
|
110
|
+
? parseManagedRuleNames(await fs.readFile(projectFile, 'utf8')) : new Set()
|
|
111
|
+
const claude = path.join(root, '.claude')
|
|
112
|
+
const opencode = path.join(root, ...(global ? ['.config', 'opencode'] : ['.opencode']))
|
|
113
|
+
const codexSkills = await scanExistingCodexSkills(root)
|
|
114
|
+
if (global && await fs.pathExists(path.join(codexHome, 'skills'))) {
|
|
115
|
+
for (const name of await fs.readdir(path.join(codexHome, 'skills'))) codexSkills.add(name)
|
|
308
116
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
normalized[platform] = {}
|
|
315
|
-
for (const [type, names] of Object.entries(types || {})) {
|
|
316
|
-
normalized[platform][type] = new Set(Array.isArray(names) ? names : [...names])
|
|
317
|
-
}
|
|
117
|
+
return {
|
|
118
|
+
claude: { skills: await scanExisting(claude, 'skills'), rules: unionSets(projectRules, await scanExisting(claude, 'rules')), hooks: await scanExisting(claude, 'hooks') },
|
|
119
|
+
codex: { skills: codexSkills, rules: projectRules, hooks: await scanExistingCodexHooks(root, global ? codexHome : path.join(root, '.codex')) },
|
|
120
|
+
opencode: { skills: await scanExisting(opencode, 'skills'), rules: projectRules, hooks: new Set() },
|
|
121
|
+
cursor: { rules: await scanExistingCursorRules(root), skills: new Set(), hooks: new Set() },
|
|
318
122
|
}
|
|
319
|
-
return normalized
|
|
320
123
|
}
|
|
321
124
|
|
|
322
|
-
function
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const currentManaged = await collectCurrentManagedNames(projectDir, global)
|
|
331
|
-
const staleEntries = createEmptyRemovalState()
|
|
332
|
-
|
|
333
|
-
for (const platform of selectedPlatforms) {
|
|
334
|
-
const previous = syncState.platforms?.[platform] || {}
|
|
335
|
-
const platformManaged = currentManaged[platform] || {}
|
|
336
|
-
|
|
337
|
-
for (const type of PLATFORM_SYNC_TYPES[platform] || []) {
|
|
338
|
-
const previousNames = previous[type] || []
|
|
339
|
-
const currentNames = currentTemplateNames[type] || new Set()
|
|
340
|
-
const managedNames = platformManaged[type] || new Set()
|
|
341
|
-
const staleNames = []
|
|
342
|
-
|
|
343
|
-
for (const name of previousNames) {
|
|
344
|
-
if (currentNames.has(name)) continue
|
|
345
|
-
if (type === 'rules' && platform === 'cursor') {
|
|
346
|
-
if (!managedNames.has(buildCursorRuleName(name))) continue
|
|
347
|
-
} else if (!managedNames.has(name)) {
|
|
348
|
-
continue
|
|
349
|
-
}
|
|
350
|
-
staleNames.push(name)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (staleNames.length > 0) staleEntries[platform][type] = staleNames.sort((a, b) => a.localeCompare(b, 'zh'))
|
|
125
|
+
function staleEntries(scans, state, existing, platforms) {
|
|
126
|
+
const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, new Set(scans[type].map(item => item.name))]))
|
|
127
|
+
const result = {}
|
|
128
|
+
for (const platform of platforms) {
|
|
129
|
+
result[platform] = {}
|
|
130
|
+
for (const type of PLATFORM_SYNC_TYPES[platform].filter(type => current[type])) {
|
|
131
|
+
result[platform][type] = (state.platforms[platform]?.[type] || []).filter(name =>
|
|
132
|
+
!current[type].has(name) && existing[platform][type].has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
|
|
354
133
|
}
|
|
355
134
|
}
|
|
356
|
-
|
|
357
|
-
return staleEntries
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* 仅 Skills 模式:陈旧项提示只保留 skills,避免问 rules/commands/hooks。
|
|
362
|
-
*
|
|
363
|
-
* @param {Record<string, Record<string, string[]>>} staleEntries
|
|
364
|
-
*/
|
|
365
|
-
function retainSkillStaleEntries(staleEntries) {
|
|
366
|
-
const next = createEmptyRemovalState()
|
|
367
|
-
for (const [platform, types] of Object.entries(staleEntries)) {
|
|
368
|
-
if (types.skills?.length) next[platform] = { skills: types.skills }
|
|
369
|
-
}
|
|
370
|
-
return next
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function getPreservedStaleNames(staleEntries, approvedRemovals, platform, type) {
|
|
374
|
-
const staleNames = staleEntries[platform]?.[type] || []
|
|
375
|
-
const approvedNames = approvedRemovals[platform]?.[type] || new Set()
|
|
376
|
-
return new Set(staleNames.filter(name => !approvedNames.has(name)))
|
|
135
|
+
return result
|
|
377
136
|
}
|
|
378
137
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const approvedNames = approvedRemovals[platform]?.[type] || new Set()
|
|
386
|
-
if (!approvedNames.has(name)) selectedNames.add(name)
|
|
387
|
-
}
|
|
388
|
-
state[type] = [...selectedNames].sort((a, b) => a.localeCompare(b, 'zh'))
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
return state
|
|
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
|
|
392
144
|
}
|
|
393
145
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
for (const name of existingNames) {
|
|
398
|
-
if (!managedNames.has(name)) continue
|
|
399
|
-
await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot })
|
|
400
|
-
logAction(`${labelPrefix}/${name}`, 'red', '×')
|
|
401
|
-
removedCount++
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
return removedCount
|
|
146
|
+
function normalizeRemovals(raw) {
|
|
147
|
+
return Object.fromEntries(Object.entries(raw).map(([platform, types]) => [platform,
|
|
148
|
+
Object.fromEntries(Object.entries(types).map(([type, names]) => [type, new Set(names)]))]))
|
|
405
149
|
}
|
|
406
150
|
|
|
407
|
-
/**
|
|
408
|
-
* `dwy` / `dwy sync` 直接进入内容选择,再选位置和平台。
|
|
409
|
-
* action 仅供已有脚本调用;主交互不再显示动作菜单。
|
|
410
|
-
*
|
|
411
|
-
* @param {object} [opts]
|
|
412
|
-
*/
|
|
151
|
+
/** `dwy` 和 `dwy sync` 共用范围 → 平台 → 内容 → 逐项选择 → 清单确认。 */
|
|
413
152
|
export async function runDwy(opts = {}) {
|
|
414
153
|
console.log(chalk.bold.cyan('\ndwy') + chalk.gray(' · AI 开发工具\n'))
|
|
415
154
|
const action = normalizeAction(opts.action)
|
|
416
|
-
if (action === ACTION_INSTALL_SKILLS) {
|
|
417
|
-
await installSkills()
|
|
418
|
-
return
|
|
419
|
-
}
|
|
155
|
+
if (action === ACTION_INSTALL_SKILLS) { await installSkills(); return }
|
|
420
156
|
if (action === ACTION_UPGRADE) {
|
|
421
|
-
try {
|
|
422
|
-
await selfUpgrade()
|
|
423
|
-
} catch (error) {
|
|
424
|
-
console.error(error.message)
|
|
425
|
-
process.exitCode = 1
|
|
426
|
-
}
|
|
157
|
+
try { await selfUpgrade() } catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
427
158
|
return
|
|
428
159
|
}
|
|
429
|
-
|
|
160
|
+
return syncAll(opts)
|
|
430
161
|
}
|
|
431
162
|
|
|
432
163
|
/**
|
|
@@ -439,13 +170,16 @@ export async function migrateSkills({
|
|
|
439
170
|
homeDir = os.homedir(),
|
|
440
171
|
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
|
|
441
172
|
} = {}) {
|
|
173
|
+
const realProject = await fs.realpath(projectDir)
|
|
174
|
+
if (await fs.pathExists(homeDir) && realProject === await fs.realpath(homeDir)) {
|
|
175
|
+
throw new Error('当前目录是用户主目录,请使用全局同步或进入项目目录迁移')
|
|
176
|
+
}
|
|
442
177
|
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
443
178
|
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
444
179
|
const state = await loadSyncState(projectDir)
|
|
445
180
|
const existingByPlatform = {}
|
|
446
181
|
const neededNames = new Set()
|
|
447
182
|
const retained = []
|
|
448
|
-
const realProject = await fs.realpath(projectDir)
|
|
449
183
|
for (const platform of ['claude', 'codex', 'opencode']) {
|
|
450
184
|
const root = resolveProjectSkillDir(projectDir, platform)
|
|
451
185
|
if (!await fs.pathExists(root)) continue
|
|
@@ -466,7 +200,7 @@ export async function migrateSkills({
|
|
|
466
200
|
}
|
|
467
201
|
}
|
|
468
202
|
}
|
|
469
|
-
const shared = await
|
|
203
|
+
const shared = await ensureSharedSkills({ skills: [...neededNames].map(name => available.get(name)), homeDir })
|
|
470
204
|
let linked = 0
|
|
471
205
|
let globalCount = 0
|
|
472
206
|
for (const [platform, names] of Object.entries(existingByPlatform)) {
|
|
@@ -485,310 +219,133 @@ export async function migrateSkills({
|
|
|
485
219
|
return { linked, global: globalCount, retained }
|
|
486
220
|
}
|
|
487
221
|
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 只执行点名范围和内容;测试/脚本可注入选择,确认前只读。
|
|
225
|
+
* contentTypes 未传时,已有脚本按 selected 中明确提供的字段处理;主交互默认只选 Skills。
|
|
226
|
+
*/
|
|
488
227
|
export async function syncAll({
|
|
489
|
-
sourceDir: sourceDirOverride,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// 兼容已有脚本;主交互的位置只控制 Skills / Hooks。
|
|
495
|
-
skillScope: skillScopeOverride,
|
|
496
|
-
// all = 完整同步;skills = 只写 skill,测试/脚本可注入
|
|
497
|
-
syncMode: syncModeOverride,
|
|
498
|
-
// 全局配置根目录;测试必须注入临时用户目录。
|
|
499
|
-
homeDir = os.homedir(),
|
|
500
|
-
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
|
|
501
|
-
scope: scopeOverride,
|
|
502
|
-
confirmed,
|
|
228
|
+
sourceDir: sourceDirOverride, projectDir: projectDirOverride, selected: selectedOverride,
|
|
229
|
+
selectedPlatforms: platformsOverride, contentTypes: typesOverride, staleRemovals: removalsOverride,
|
|
230
|
+
skillScope: skillScopeOverride, syncMode: modeOverride, homeDir = os.homedir(),
|
|
231
|
+
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'), scope: scopeOverride, confirmed,
|
|
232
|
+
contentActions: actionsOverride,
|
|
503
233
|
} = {}) {
|
|
504
234
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
505
|
-
if (!await fs.pathExists(sourceDir))
|
|
506
|
-
throw new Error('ai-tools templates not found in repo')
|
|
507
|
-
}
|
|
508
|
-
|
|
235
|
+
if (!await fs.pathExists(sourceDir)) throw new Error('ai-tools templates not found in repo')
|
|
509
236
|
const projectDir = projectDirOverride || process.cwd()
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
skills: await scanSkills(sourceDir),
|
|
513
|
-
rules: await scanRules(sourceDir),
|
|
514
|
-
commands: [],
|
|
515
|
-
hooks: await scanHooks(sourceDir),
|
|
516
|
-
}
|
|
517
|
-
const existing = await scanCombinedExisting(projectDir)
|
|
518
|
-
const syncState = await loadSyncState(projectDir)
|
|
519
|
-
const globalState = await loadSyncState(homeDir)
|
|
520
|
-
const projectDefaults = buildSelectionDefaultsFromSyncState(existing, syncState)
|
|
521
|
-
const globalExisting = await scanCombinedExisting(homeDir, true)
|
|
522
|
-
globalExisting.skills = await scanGlobalSkills({ homeDir, codexHome })
|
|
523
|
-
const globalDefaults = buildSelectionDefaultsFromSyncState(globalExisting, globalState)
|
|
524
|
-
globalDefaults.skills = unionSets(globalDefaults.skills, globalExisting.skills)
|
|
525
|
-
const selectionDefaults = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, unionSets(projectDefaults[type], globalDefaults[type])]))
|
|
526
|
-
selectionDefaults.rules = projectDefaults.rules
|
|
527
|
-
const syncMode = normalizeSyncMode(syncModeOverride)
|
|
528
|
-
const selected = selectedOverride || await interactiveSelect(scans, selectionDefaults, {
|
|
529
|
-
skillsOnly: syncMode === SYNC_MODE_SKILLS,
|
|
530
|
-
})
|
|
531
|
-
if (selected === null) {
|
|
532
|
-
console.log(chalk.yellow('\n已取消同步。'))
|
|
533
|
-
return
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (syncMode === SYNC_MODE_ALL) selected.hooks = scans.hooks
|
|
537
|
-
|
|
538
|
-
const scopeResult = scopeOverride || (selectedOverride || selectedPlatformsOverride ? 'project' : await searchableSelect({
|
|
539
|
-
message: '选择同步位置(Skills / Hooks)',
|
|
237
|
+
const scope = scopeOverride || (selectedOverride || platformsOverride ? 'project' : await searchableSelect({
|
|
238
|
+
message: '选择同步范围',
|
|
540
239
|
options: [
|
|
541
|
-
{ value: 'project', label: '当前项目', description: 'Skills
|
|
542
|
-
{ value: 'global', label: '全局', description: 'Skills
|
|
543
|
-
],
|
|
544
|
-
initialValue: 'project',
|
|
240
|
+
{ value: 'project', label: '当前项目', description: '管理 Skills、Hooks、Rules 和通用规范;继承有效全局配置' },
|
|
241
|
+
{ value: 'global', label: '全局', description: '管理 Skills、Hooks 和通用规范;清理当前项目重复入口' },
|
|
242
|
+
], initialValue: 'project',
|
|
545
243
|
}))
|
|
546
|
-
if (isCancel(
|
|
547
|
-
if (!['project', 'global'].includes(
|
|
548
|
-
const global =
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const staleEntries = syncMode === SYNC_MODE_SKILLS ? retainSkillStaleEntries(staleEntriesRaw) : staleEntriesRaw
|
|
564
|
-
const approvedStaleRemovals = normalizeRemovalInput(staleRemovalsOverride || staleEntries)
|
|
565
|
-
const skillScope = normalizeSkillScope(skillScopeOverride)
|
|
566
|
-
const baseline = syncMode === SYNC_MODE_ALL ? await readBaselineDoc(sourceDir) : ''
|
|
567
|
-
const cliPlatforms = selectedPlatforms.filter(platform => platform !== 'cursor')
|
|
568
|
-
const baselinePaths = {
|
|
569
|
-
claude: path.join(homeDir, '.claude', 'CLAUDE.md'),
|
|
570
|
-
codex: path.join(codexHome, 'AGENTS.md'),
|
|
571
|
-
opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md'),
|
|
572
|
-
}
|
|
573
|
-
const legacyClaudeRules = new Set()
|
|
574
|
-
if (syncMode === SYNC_MODE_ALL && selectedPlatforms.includes('claude')) {
|
|
575
|
-
for (const [root, previousNames] of [
|
|
576
|
-
[projectDir, approvedStaleRemovals.claude?.rules || []],
|
|
577
|
-
...(baseline.trim() ? [[homeDir, globalState.platforms.claude?.rules || []]] : []),
|
|
578
|
-
]) {
|
|
579
|
-
for (const name of new Set([...scans.rules.map(rule => rule.name), ...previousNames])) {
|
|
580
|
-
if (path.basename(name) !== name || !name.endsWith('.md')) continue
|
|
581
|
-
const dest = path.join(root, '.claude', 'rules', name)
|
|
582
|
-
if (await fs.pathExists(dest)) legacyClaudeRules.add(dest)
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
if ((!selectedOverride && confirmed !== true) || confirmed === false) {
|
|
587
|
-
const currentManaged = await collectCurrentManagedNames(targetRoot, global)
|
|
588
|
-
const projectManaged = await collectCurrentManagedNames(projectDir)
|
|
589
|
-
for (const platform of selectedPlatforms) currentManaged[platform].rules = projectManaged[platform].rules
|
|
590
|
-
const templateNames = getCurrentTemplateNames(scans)
|
|
591
|
-
console.log(`\nSkills:${selected.skills.map(item => item.name).join('、') || '未选择'}`)
|
|
592
|
-
console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '未选择'}`)
|
|
593
|
-
console.log(`Skills / Hooks 位置:${global ? '全局' : '当前项目'};平台:${selectedPlatforms.map(platform => PLATFORM_LABELS[platform]).join('、')}`)
|
|
594
|
-
if (syncMode === SYNC_MODE_ALL) {
|
|
595
|
-
for (const platform of cliPlatforms) console.log(`全局基线 ${PLATFORM_LABELS[platform]} → ${baselinePaths[platform]}(替换基线并清除旧托管 Rules)`)
|
|
596
|
-
if (cliPlatforms.length) console.log(`项目 Rules → ${path.join(projectDir, 'AGENTS.md')}(保留手写内容,移除旧托管基线)`)
|
|
597
|
-
if (selectedPlatforms.includes('claude')) console.log('旧 Claude 基线入口仅清理 dwy 托管块或与当前模板完全一致的内容。')
|
|
598
|
-
for (const dest of legacyClaudeRules) console.log(`删除旧 Claude Rules → ${dest}`)
|
|
599
|
-
}
|
|
600
|
-
console.log('Hooks 按支持的平台自动启用。')
|
|
601
|
-
console.log('Skills 将迁为用户共享副本的目录链接;原真实目录先备份,同平台全局入口优先。')
|
|
602
|
-
for (const platform of selectedPlatforms) {
|
|
603
|
-
for (const type of PLATFORM_SYNC_TYPES[platform]) {
|
|
604
|
-
const existingNames = currentManaged[platform]?.[type] || new Set()
|
|
605
|
-
const previousState = type === 'rules' ? syncState : targetState
|
|
606
|
-
const previous = [...new Set([...templateNames[type], ...(previousState.platforms[platform]?.[type] || [])])]
|
|
607
|
-
.filter(name => existingNames.has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
|
|
608
|
-
const names = new Set((selected[type] || []).map(item => item.name))
|
|
609
|
-
const removed = previous.filter(name => !names.has(name))
|
|
610
|
-
const changed = [...names].filter(name => existingNames.has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
|
|
611
|
-
if (changed.length) console.log(`更新 ${PLATFORM_LABELS[platform]} ${type}:${changed.join('、')}`)
|
|
612
|
-
if (removed.length) console.log(`删除 ${PLATFORM_LABELS[platform]} ${type}:${removed.join('、')}`)
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
const answer = confirmed === false ? false : await confirm({ message: '确认同步?' })
|
|
616
|
-
if (isCancel(answer) || !answer) { console.log(chalk.yellow('已取消同步。')); return }
|
|
617
|
-
}
|
|
618
|
-
if (!selectedOverride && selected.skills.some(item => item.category === '产品0到1')) await ensureSkillsInstalled()
|
|
619
|
-
|
|
620
|
-
const selectedSkillNames = new Set((selected.skills || []).map(item => item.name))
|
|
621
|
-
const globalSkillNameSet = new Set(skillScope.globalSkills)
|
|
622
|
-
const neededNames = new Set(skillScope.destinations.some(id => id !== 'project') ? globalSkillNameSet : [])
|
|
623
|
-
if (selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))) {
|
|
624
|
-
for (const name of selectedSkillNames) neededNames.add(name)
|
|
625
|
-
}
|
|
626
|
-
for (const platform of selectedPlatforms) {
|
|
627
|
-
for (const skill of scans.skills) {
|
|
628
|
-
if (await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) neededNames.add(skill.name)
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const sharedSkills = await prepareSharedSkills({ skills: scans.skills.filter(item => neededNames.has(item.name)), homeDir })
|
|
632
|
-
const skillsToWrite = sharedSkills.filter(item => selectedSkillNames.has(item.name))
|
|
633
|
-
const globalSkills = sharedSkills.filter(item => globalSkillNameSet.has(item.name))
|
|
634
|
-
if (globalSkills.length > 0 && skillScope.destinations.some(id => id !== 'project')) {
|
|
635
|
-
await copySkillsToGlobalDirs({ skills: globalSkills, destIds: skillScope.destinations, homeDir })
|
|
636
|
-
}
|
|
637
|
-
const duplicateNames = {}
|
|
638
|
-
if (!global) {
|
|
639
|
-
for (const platform of selectedPlatforms) {
|
|
640
|
-
duplicateNames[platform] = await deduplicateProjectSkills({ projectDir, platform, skills: sharedSkills, homeDir, codexHome })
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const selectionFor = platform => ({
|
|
644
|
-
...selected,
|
|
645
|
-
skills: selected.skills.filter(skill => !duplicateNames[platform]?.has(skill.name)),
|
|
646
|
-
})
|
|
647
|
-
let syncedAnySupportedPlatform = false
|
|
648
|
-
|
|
649
|
-
if (syncMode === SYNC_MODE_SKILLS) {
|
|
650
|
-
const staleSkillNames = new Set()
|
|
651
|
-
for (const platform of selectedPlatforms) {
|
|
652
|
-
for (const name of approvedStaleRemovals[platform]?.skills || []) staleSkillNames.add(name)
|
|
653
|
-
}
|
|
654
|
-
await syncSkillsOnly({
|
|
655
|
-
projectDir,
|
|
656
|
-
selectedPlatforms,
|
|
657
|
-
skills: skillsToWrite,
|
|
658
|
-
templateSkillNames: new Set(scans.skills.map(item => item.name)),
|
|
659
|
-
staleSkillNames,
|
|
660
|
-
skillScope,
|
|
661
|
-
homeDir,
|
|
662
|
-
global,
|
|
663
|
-
duplicateNames,
|
|
244
|
+
if (isCancel(scope)) { console.log(chalk.yellow('已取消同步。')); return }
|
|
245
|
+
if (!['project', 'global'].includes(scope)) throw new Error('无效同步范围')
|
|
246
|
+
const global = scope === 'global', targetRoot = global ? homeDir : projectDir
|
|
247
|
+
const platforms = platformsOverride || await selectPlatforms(targetRoot, scope, codexHome)
|
|
248
|
+
if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
|
|
249
|
+
if (platforms.length === 0 || platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))) throw new Error('所选平台不支持此同步范围')
|
|
250
|
+
const options = contentOptions(scope, platforms)
|
|
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
|
|
254
|
+
let contentTypes = typesOverride
|
|
255
|
+
if (!contentTypes && modeOverride === SYNC_MODE_SKILLS) contentTypes = ['skills']
|
|
256
|
+
if (!contentTypes && selectedOverride) contentTypes = Object.keys(selectedOverride).filter(type => allowed.has(type))
|
|
257
|
+
if (!contentTypes) {
|
|
258
|
+
const result = await searchableMultiselect({
|
|
259
|
+
message: '选择本次处理内容(未勾选的类型保持原样)', options,
|
|
260
|
+
initialValues: defaultContentTypes(scope, platforms, targetState), required: true, maxItems: 5,
|
|
664
261
|
})
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
projectDir: targetRoot,
|
|
700
|
-
global,
|
|
701
|
-
selected: selectionFor('codex'),
|
|
702
|
-
homeDir,
|
|
703
|
-
sharedSkills,
|
|
704
|
-
staleRemovals: approvedStaleRemovals.codex,
|
|
705
|
-
})
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (selectedPlatforms.includes('cursor')) {
|
|
709
|
-
syncedAnySupportedPlatform = true
|
|
710
|
-
await syncCursor({
|
|
711
|
-
sourceDir,
|
|
712
|
-
projectDir,
|
|
713
|
-
selected,
|
|
714
|
-
staleRemovals: approvedStaleRemovals.cursor,
|
|
715
|
-
})
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
if (selectedPlatforms.includes('opencode')) {
|
|
719
|
-
syncedAnySupportedPlatform = true
|
|
720
|
-
await syncOpenCode({
|
|
721
|
-
sourceDir,
|
|
722
|
-
projectDir: targetRoot,
|
|
723
|
-
global,
|
|
724
|
-
selected: selectionFor('opencode'),
|
|
725
|
-
homeDir,
|
|
726
|
-
sharedSkills,
|
|
727
|
-
staleRemovals: approvedStaleRemovals.opencode,
|
|
728
|
-
})
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
if (global) {
|
|
735
|
-
for (const platform of selectedPlatforms) {
|
|
736
|
-
duplicateNames[platform] = await deduplicateProjectSkills({ projectDir, platform, skills: sharedSkills, homeDir, codexHome })
|
|
737
|
-
if (syncState.platforms[platform]) {
|
|
738
|
-
syncState.platforms[platform].skills = syncState.platforms[platform].skills.filter(name => !duplicateNames[platform].has(name))
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (syncMode === SYNC_MODE_SKILLS) {
|
|
744
|
-
for (const platform of selectedPlatforms) {
|
|
745
|
-
const previous = targetState.platforms[platform] || createEmptyPlatformState()
|
|
746
|
-
const skillsOnlySelected = { skills: (global ? selected : selectionFor(platform)).skills, rules: [], commands: [], hooks: [] }
|
|
747
|
-
targetState.platforms[platform] = {
|
|
748
|
-
...previous,
|
|
749
|
-
skills: buildNextPlatformState(skillsOnlySelected, staleEntries, approvedStaleRemovals, platform).skills,
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
} else {
|
|
753
|
-
for (const platform of selectedPlatforms) {
|
|
754
|
-
const next = buildNextPlatformState(global ? selected : selectionFor(platform), staleEntries, approvedStaleRemovals, platform)
|
|
755
|
-
if (global) {
|
|
756
|
-
syncState.platforms[platform] = {
|
|
757
|
-
...(syncState.platforms[platform] || createEmptyPlatformState()),
|
|
758
|
-
rules: next.rules,
|
|
262
|
+
if (isCancel(result)) { console.log(chalk.yellow('已取消同步。')); return }
|
|
263
|
+
contentTypes = result
|
|
264
|
+
}
|
|
265
|
+
if (contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
|
|
266
|
+
if (contentTypes.length === 0) { console.log('未选择处理内容。'); return }
|
|
267
|
+
const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
|
|
268
|
+
const existing = await managedNames(targetRoot, global, codexHome)
|
|
269
|
+
const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type,
|
|
270
|
+
unionSets(...platforms.map(platform => existing[platform][type]))]))
|
|
271
|
+
const defaults = buildSelectionDefaultsFromSyncState(current, targetState, platforms)
|
|
272
|
+
for (const type of ['skills', 'rules', 'hooks']) defaults[type] = unionSets(defaults[type], current[type])
|
|
273
|
+
const choices = !global && contentTypes.includes('skills')
|
|
274
|
+
? { ...scans, skills: await projectSkillChoices(scans.skills, platforms, { homeDir, codexHome }) } : scans
|
|
275
|
+
const selected = selectedOverride
|
|
276
|
+
? { skills: selectedOverride.skills || [], rules: selectedOverride.rules || [], hooks: [], commands: [] }
|
|
277
|
+
: await interactiveSelect(choices, defaults, { types: contentTypes })
|
|
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('、')}`)
|
|
759
296
|
}
|
|
760
|
-
|
|
761
|
-
} else {
|
|
762
|
-
targetState.platforms[platform] = next
|
|
297
|
+
inheritedHooks = total > 0 && covered === total
|
|
763
298
|
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
for (const platform of Object.keys(baselinePaths)) {
|
|
775
|
-
if (syncState.platforms[platform]) syncState.platforms[platform].rules = rules
|
|
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
|
|
776
309
|
}
|
|
777
310
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
311
|
+
const stale = staleEntries(scans, targetState, existing, platforms)
|
|
312
|
+
const removals = normalizeRemovals(removalsOverride || stale)
|
|
313
|
+
const baseline = contentTypes.some(type => type === 'rules' || type === 'baseline') ? await readBaselineDoc(sourceDir) : ''
|
|
314
|
+
const plan = await buildSyncPlan({
|
|
315
|
+
scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
|
|
316
|
+
projectState, globalState, staleRemovals: removals, staleEntries: stale, baseline,
|
|
317
|
+
contentActions,
|
|
318
|
+
skillScope: normalizeSkillScope(skillScopeOverride),
|
|
319
|
+
})
|
|
320
|
+
const nextTarget = global ? plan.nextGlobal : plan.nextProject
|
|
321
|
+
nextTarget.contentTypes = contentTypes
|
|
322
|
+
nextTarget.syncMode = contentTypes.length === 1 && contentTypes[0] === 'skills' ? SYNC_MODE_SKILLS : SYNC_MODE_ALL
|
|
323
|
+
nextTarget.skillScope = normalizeSkillScope(skillScopeOverride)
|
|
324
|
+
const stateFile = path.join(targetRoot, ...SYNC_STATE_PATH)
|
|
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
|
+
} })
|
|
330
|
+
if (global && plan.projectChanged) plan.operations.push({
|
|
331
|
+
kind: 'state', label: '更新项目去重记录', paths: [path.join(projectDir, ...SYNC_STATE_PATH)],
|
|
332
|
+
run: async () => { await assertConfigPath(path.join(projectDir, ...SYNC_STATE_PATH), projectDir); await saveSyncState(projectDir, plan.nextProject) },
|
|
333
|
+
})
|
|
334
|
+
if (global && plan.projectChanged) await assertConfigPath(path.join(projectDir, ...SYNC_STATE_PATH), projectDir)
|
|
335
|
+
console.log(`\n同步范围:${global ? '全局' : '当前项目'};平台:${platforms.map(p => PLATFORM_LABELS[p]).join('、')}`)
|
|
336
|
+
console.log(`处理内容:${contentTypes.map(type => options.find(option => option.value === type).label).join('、')}`)
|
|
337
|
+
if (contentTypes.includes('skills')) console.log(`Skills:${selected.skills.map(item => item.name).join('、') || '无项目安装选择;已有全局入口仅去重'}`)
|
|
338
|
+
if (contentTypes.includes('rules')) console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '移除当前项目托管规则'}`)
|
|
339
|
+
if (contentTypes.includes('baseline')) console.log(`通用规范:${contentActions.baseline === 'remove' ? '显式移除' : baseline.split('\n').filter(line => /^#/.test(line)).slice(0,3).join(';')}`)
|
|
340
|
+
for (const operation of plan.operations) {
|
|
341
|
+
console.log(operation.label)
|
|
342
|
+
for (const dest of operation.paths) console.log(` → ${dest}`)
|
|
789
343
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
console.log(chalk.yellow('
|
|
344
|
+
if ((!selectedOverride && confirmed !== true) || confirmed === false) {
|
|
345
|
+
const answer = confirmed === false ? false : await confirm({ message: '确认同步?' })
|
|
346
|
+
if (isCancel(answer) || !answer) { console.log(chalk.yellow('已取消同步。')); return }
|
|
793
347
|
}
|
|
348
|
+
for (const operation of plan.operations) await operation.run()
|
|
349
|
+
console.log(chalk.green('同步完成。'))
|
|
350
|
+
return plan.operations.map(({ kind, label, paths }) => ({ kind, label, paths }))
|
|
794
351
|
}
|