create-dwy 0.21.0 → 0.23.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 +24 -0
- package/bin/index.js +6 -3
- package/package.json +1 -1
- package/src/cli-help.js +29 -13
- package/src/self-upgrade.js +7 -0
- package/src/skill-store.js +197 -0
- package/src/skills-install.js +47 -11
- package/src/sync-all.js +216 -605
- package/src/sync-codex.js +48 -25
- package/src/sync-global-skills.js +64 -14
- package/src/sync-opencode.js +9 -5
- package/src/sync-plan.js +278 -0
- package/src/sync.js +33 -18
- package/src/tab-multiselect.js +7 -5
- package/templates/ai-tools/AGENTS.md +1 -1
- package/templates/ai-tools/CHANGELOG.md +6 -0
- package/templates/ai-tools/VERSION +1 -1
- package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/progr/sql/createExternalTable.md +2 -2
- package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tools/superset.md +1 -1
- package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tutorials/Migrate_data_from_InfluxDB_to_DolphinDB.md +1 -1
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +18 -25
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +3 -3
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/backend-deploy.md +4 -4
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +6 -70
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/frontend-deploy.md +3 -3
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/generic-release.md +5 -5
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/git-tag.md +4 -79
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +2 -2
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +3 -3
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/publish-config.md +4 -4
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/version-sync.md +7 -7
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +3 -7
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/SKILL.md +58 -0
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/delegation-guide.md +46 -0
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/review-guide.md +57 -0
package/src/sync-codex.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { chalk } from './utils.js'
|
|
4
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
4
5
|
import {
|
|
5
6
|
extractDescription,
|
|
6
7
|
loadHookManifests,
|
|
@@ -186,15 +187,14 @@ export async function scanExistingCodexSkills(proj) {
|
|
|
186
187
|
const dir = path.join(proj, '.agents', 'skills')
|
|
187
188
|
if (!await fs.pathExists(dir)) return new Set()
|
|
188
189
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
189
|
-
return new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
|
|
190
|
+
return new Set(entries.filter(e => e.isDirectory() || e.isSymbolicLink()).map(e => e.name))
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
async function copySkillsFlat(skills, proj) {
|
|
193
194
|
const root = path.join(proj, '.agents', 'skills')
|
|
194
195
|
for (const s of skills) {
|
|
195
196
|
const dest = path.join(root, s.name)
|
|
196
|
-
await
|
|
197
|
-
await fs.copy(s.sourcePath, dest, { overwrite: true, filter: copyFilter(s.sourcePath) })
|
|
197
|
+
await linkSkill({ skill: s, dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
|
|
198
198
|
logAction(`.agents/skills/${s.name}`)
|
|
199
199
|
}
|
|
200
200
|
return skills.length
|
|
@@ -206,7 +206,12 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
|
|
|
206
206
|
if (selectedNames.has(name)) continue
|
|
207
207
|
// 项目独有(模板未提供过)的条目永不删除,保留用户自定义
|
|
208
208
|
if (!templateNames.has(name)) continue
|
|
209
|
-
|
|
209
|
+
const dest = path.join(proj, ...subdir, name)
|
|
210
|
+
if (subdir[1] === 'skills') {
|
|
211
|
+
await removeSkillEntry({ dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
|
|
212
|
+
} else {
|
|
213
|
+
await fs.remove(dest)
|
|
214
|
+
}
|
|
210
215
|
logAction(`${subdir.join('/')}/${name}`, 'red', '×')
|
|
211
216
|
count++
|
|
212
217
|
}
|
|
@@ -215,16 +220,16 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
|
|
|
215
220
|
|
|
216
221
|
// ---------- hooks → .codex/ ----------
|
|
217
222
|
|
|
218
|
-
export async function scanExistingCodexHooks(proj) {
|
|
219
|
-
const dir = path.join(
|
|
223
|
+
export async function scanExistingCodexHooks(proj, configDir = path.join(proj, '.codex')) {
|
|
224
|
+
const dir = path.join(configDir, 'hooks')
|
|
220
225
|
if (!await fs.pathExists(dir)) return new Set()
|
|
221
226
|
const entries = await fs.readdir(dir)
|
|
222
227
|
return new Set(entries.filter(n => n !== '.gitkeep' && n !== '.DS_Store'))
|
|
223
228
|
}
|
|
224
229
|
|
|
225
|
-
async function copyCodexHooks(hooks,
|
|
230
|
+
async function copyCodexHooks(hooks, configDir) {
|
|
226
231
|
if (hooks.length === 0) return 0
|
|
227
|
-
const dir = path.join(
|
|
232
|
+
const dir = path.join(configDir, 'hooks')
|
|
228
233
|
await fs.ensureDir(dir)
|
|
229
234
|
for (const h of hooks) {
|
|
230
235
|
if (!await fs.pathExists(h.sourcePath)) {
|
|
@@ -243,7 +248,12 @@ async function copyCodexHooks(hooks, proj) {
|
|
|
243
248
|
return hooks.length
|
|
244
249
|
}
|
|
245
250
|
|
|
246
|
-
function
|
|
251
|
+
function globalHookCommand(configDir, name) {
|
|
252
|
+
const script = path.join(configDir, 'hooks', name).replace(/\\/g, '/')
|
|
253
|
+
return `bash '${script.replace(/'/g, "'\\''")}'`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
|
|
247
257
|
const hooksJson = { hooks: {} }
|
|
248
258
|
const grouped = new Map()
|
|
249
259
|
|
|
@@ -258,7 +268,7 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
|
|
|
258
268
|
hooks.push({
|
|
259
269
|
type: 'command',
|
|
260
270
|
command: global
|
|
261
|
-
?
|
|
271
|
+
? globalHookCommand(configDir, manifest.name)
|
|
262
272
|
: `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
|
|
263
273
|
...(manifest.timeout ? { timeout: manifest.timeout } : {}),
|
|
264
274
|
})
|
|
@@ -274,13 +284,14 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
|
|
|
274
284
|
return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
|
|
275
285
|
}
|
|
276
286
|
|
|
277
|
-
function isManagedCodexHookCommand(command, managedHookNames) {
|
|
287
|
+
function isManagedCodexHookCommand(command, managedHookNames, configDir) {
|
|
278
288
|
if (typeof command !== 'string') return false
|
|
279
289
|
const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
|
|
280
290
|
return !!(match && managedHookNames.has(match[1]))
|
|
291
|
+
|| [...managedHookNames].some(name => command === globalHookCommand(configDir, name))
|
|
281
292
|
}
|
|
282
293
|
|
|
283
|
-
function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
294
|
+
function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
|
|
284
295
|
const merged = {}
|
|
285
296
|
const sourceEvents = new Set([
|
|
286
297
|
...Object.keys(existingHooks || {}),
|
|
@@ -291,7 +302,7 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
|
291
302
|
const kept = []
|
|
292
303
|
|
|
293
304
|
for (const cfg of existingHooks?.[event] || []) {
|
|
294
|
-
const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames))
|
|
305
|
+
const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames, configDir))
|
|
295
306
|
if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
|
|
296
307
|
}
|
|
297
308
|
|
|
@@ -302,10 +313,10 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
|
302
313
|
return Object.keys(merged).length > 0 ? merged : undefined
|
|
303
314
|
}
|
|
304
315
|
|
|
305
|
-
async function syncCodexHooksJson(sourceDir,
|
|
306
|
-
const dest = path.join(
|
|
316
|
+
async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global) {
|
|
317
|
+
const dest = path.join(configDir, 'hooks.json')
|
|
307
318
|
const manifests = await loadHookManifests(sourceDir)
|
|
308
|
-
const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
|
|
319
|
+
const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
|
|
309
320
|
const managedHookNames = new Set(
|
|
310
321
|
manifests
|
|
311
322
|
.filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
|
|
@@ -315,7 +326,7 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
|
|
|
315
326
|
let nextJson
|
|
316
327
|
if (await fs.pathExists(dest)) {
|
|
317
328
|
const existing = await fs.readJson(dest)
|
|
318
|
-
const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames)
|
|
329
|
+
const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames, configDir)
|
|
319
330
|
if (!nextHooks) {
|
|
320
331
|
if (!existing.hooks) return 0
|
|
321
332
|
const { hooks, ...rest } = existing
|
|
@@ -339,12 +350,16 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
|
|
|
339
350
|
return 1
|
|
340
351
|
}
|
|
341
352
|
|
|
342
|
-
/** 同步 Codex
|
|
353
|
+
/** 同步 Codex Skills 与 Hooks;全局 Hooks 遵循 codexHome。 */
|
|
343
354
|
export async function syncCodex({
|
|
344
355
|
sourceDir: sourceDirOverride,
|
|
345
356
|
projectDir,
|
|
346
357
|
global = false,
|
|
347
358
|
selected,
|
|
359
|
+
homeDir,
|
|
360
|
+
codexHome,
|
|
361
|
+
sharedSkills,
|
|
362
|
+
syncTypes = ['skills', 'hooks'],
|
|
348
363
|
staleRemovals = {},
|
|
349
364
|
} = {}) {
|
|
350
365
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
@@ -353,6 +368,7 @@ export async function syncCodex({
|
|
|
353
368
|
}
|
|
354
369
|
|
|
355
370
|
const proj = projectDir || process.cwd()
|
|
371
|
+
const configDir = global && codexHome ? codexHome : path.join(proj, '.codex')
|
|
356
372
|
|
|
357
373
|
console.log(chalk.blue('\nScanning available config for Codex...\n'))
|
|
358
374
|
|
|
@@ -364,7 +380,7 @@ export async function syncCodex({
|
|
|
364
380
|
|
|
365
381
|
const existing = {
|
|
366
382
|
skills: await scanExistingCodexSkills(proj),
|
|
367
|
-
hooks: await scanExistingCodexHooks(proj),
|
|
383
|
+
hooks: await scanExistingCodexHooks(proj, configDir),
|
|
368
384
|
}
|
|
369
385
|
const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
|
|
370
386
|
const syncedSelection = {
|
|
@@ -378,9 +394,16 @@ export async function syncCodex({
|
|
|
378
394
|
let syncedCount = 0
|
|
379
395
|
let removedCount = 0
|
|
380
396
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
397
|
+
if (syncTypes.includes('skills')) {
|
|
398
|
+
const skills = sharedSkills
|
|
399
|
+
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
400
|
+
: await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
401
|
+
syncedCount += await copySkillsFlat(skills, proj)
|
|
402
|
+
}
|
|
403
|
+
if (syncTypes.includes('hooks')) {
|
|
404
|
+
syncedCount += await copyCodexHooks(syncedSelection.hooks, configDir)
|
|
405
|
+
syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global)
|
|
406
|
+
}
|
|
384
407
|
|
|
385
408
|
const skillTemplateNames = new Set([
|
|
386
409
|
...scans.skills.map(s => s.name),
|
|
@@ -390,10 +413,10 @@ export async function syncCodex({
|
|
|
390
413
|
...scans.hooks.map(h => h.name),
|
|
391
414
|
...(staleRemovals.hooks || new Set()),
|
|
392
415
|
])
|
|
393
|
-
removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
|
|
394
|
-
removedCount += await removeUnselectedFlat([
|
|
416
|
+
if (syncTypes.includes('skills')) removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
|
|
417
|
+
if (syncTypes.includes('hooks')) removedCount += await removeUnselectedFlat([path.relative(proj, configDir), 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
|
|
395
418
|
|
|
396
419
|
console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
|
|
397
420
|
console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
|
|
398
|
-
console.log(chalk.gray(` Hooks → ${
|
|
421
|
+
console.log(chalk.gray(` Hooks → ${configDir}`))
|
|
399
422
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 只覆盖本次点名的 skill,不删除全局目录里其它个人 skill。
|
|
2
|
+
* 将 Skills 安装为用户共享副本的目录链接,并处理同平台全局优先去重。
|
|
3
|
+
* 只处理模板或同步记录中的条目;个人 Skill 不参与清理,原真实目录先备份。
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
import fs from 'fs-extra'
|
|
8
7
|
import os from 'node:os'
|
|
9
8
|
import path from 'path'
|
|
10
|
-
import {
|
|
9
|
+
import { logAction, scanExisting } from './sync.js'
|
|
10
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
11
11
|
|
|
12
12
|
/** 项目目录:随所选平台落到 .claude/skills、.agents/skills 等 */
|
|
13
13
|
export const SKILL_DEST_PROJECT = 'project'
|
|
@@ -115,7 +115,7 @@ export function listSkillDestOptions(homeDir = os.homedir()) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
118
|
+
* 将 skill 的共享副本链接到已选全局 dest。destIds 含 project 时跳过。
|
|
119
119
|
* 只写传入的 skills,不清理目标根目录里其它条目。
|
|
120
120
|
*
|
|
121
121
|
* @param {{ skills: Array<{ name: string, sourcePath: string }>, destIds: string[], homeDir?: string }} opts
|
|
@@ -123,15 +123,15 @@ export function listSkillDestOptions(homeDir = os.homedir()) {
|
|
|
123
123
|
*/
|
|
124
124
|
export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.homedir() }) {
|
|
125
125
|
let count = 0
|
|
126
|
+
const sharedSkills = await ensureSharedSkills({ skills, homeDir })
|
|
126
127
|
|
|
127
128
|
for (const destId of destIds) {
|
|
128
129
|
const destRoot = resolveGlobalSkillDir(destId, homeDir)
|
|
129
130
|
if (!destRoot) continue
|
|
130
131
|
|
|
131
|
-
for (const skill of
|
|
132
|
+
for (const skill of sharedSkills) {
|
|
132
133
|
const dest = path.join(destRoot, skill.name)
|
|
133
|
-
await
|
|
134
|
-
await fs.copy(skill.sourcePath, dest, { overwrite: true, filter: copyFilter(skill.sourcePath) })
|
|
134
|
+
await linkSkill({ skill, dest, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') })
|
|
135
135
|
logAction(`${DEST_LABELS[destId] || destId}/${skill.name}`)
|
|
136
136
|
count++
|
|
137
137
|
}
|
|
@@ -141,23 +141,23 @@ export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.hom
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
*
|
|
144
|
+
* 将 skill 的共享副本链接到项目内已选平台的 skills 目录。
|
|
145
145
|
* 不写 rules / commands / hooks。
|
|
146
146
|
*
|
|
147
|
-
* @param {{ projectDir: string, platforms: string[], skills: Array<{ name: string, sourcePath: string }
|
|
147
|
+
* @param {{ projectDir: string, platforms: string[], skills: Array<{ name: string, sourcePath: string }>, homeDir?: string }} opts
|
|
148
148
|
* @returns {Promise<number>}
|
|
149
149
|
*/
|
|
150
|
-
export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills }) {
|
|
150
|
+
export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills, homeDir }) {
|
|
151
151
|
let count = 0
|
|
152
|
+
const sharedSkills = await ensureSharedSkills({ skills, homeDir })
|
|
152
153
|
|
|
153
154
|
for (const platform of platforms) {
|
|
154
155
|
const destRoot = resolveProjectSkillDir(projectDir, platform)
|
|
155
156
|
if (!destRoot) continue
|
|
156
157
|
|
|
157
|
-
for (const skill of
|
|
158
|
+
for (const skill of sharedSkills) {
|
|
158
159
|
const dest = path.join(destRoot, skill.name)
|
|
159
|
-
await
|
|
160
|
-
await fs.copy(skill.sourcePath, dest, { overwrite: true, filter: copyFilter(skill.sourcePath) })
|
|
160
|
+
await linkSkill({ skill, dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
|
|
161
161
|
logAction(`${path.relative(projectDir, destRoot)}/${skill.name}`)
|
|
162
162
|
count++
|
|
163
163
|
}
|
|
@@ -165,3 +165,53 @@ export async function copySkillsToProjectPlatforms({ projectDir, platforms, skil
|
|
|
165
165
|
|
|
166
166
|
return count
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/** 当前平台实际发现的用户级目录;OpenCode 同时兼容 Claude 与 .agents。 */
|
|
170
|
+
export function globalSkillRoots(platform, homeDir, codexHome = path.join(homeDir, '.codex')) {
|
|
171
|
+
if (platform === 'claude') return [resolveGlobalSkillDir('claude', homeDir)]
|
|
172
|
+
if (platform === 'codex') return [resolveGlobalSkillDir('agents', homeDir), path.join(codexHome, 'skills')]
|
|
173
|
+
if (platform === 'opencode') return ['opencode', 'claude', 'agents'].map(id => resolveGlobalSkillDir(id, homeDir))
|
|
174
|
+
return []
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 查找有效全局入口;坏链或目录缺少 SKILL.md 时不能作为项目去重依据。 */
|
|
178
|
+
export async function findGlobalSkill({ name, platform, homeDir, codexHome }) {
|
|
179
|
+
for (const root of globalSkillRoots(platform, homeDir, codexHome)) {
|
|
180
|
+
const dest = path.join(root, name)
|
|
181
|
+
const stat = await fs.stat(path.join(dest, 'SKILL.md')).catch(error => {
|
|
182
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
|
|
183
|
+
throw error
|
|
184
|
+
})
|
|
185
|
+
if (stat?.isFile()) return dest
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 按实际发现范围扫描有效全局 Skills,供默认选择使用;覆盖自定义 CODEX_HOME。 */
|
|
191
|
+
export async function scanGlobalSkills({ homeDir, codexHome }) {
|
|
192
|
+
const names = new Set()
|
|
193
|
+
for (const platform of ['claude', 'codex', 'opencode']) {
|
|
194
|
+
for (const root of globalSkillRoots(platform, homeDir, codexHome)) {
|
|
195
|
+
for (const name of await scanExisting(path.dirname(root), 'skills')) {
|
|
196
|
+
if (await findGlobalSkill({ name, platform, homeDir, codexHome })) names.add(name)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return names
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 全局优先:仅备份并移除项目重复入口,不改写全局提供者,返回去重名称。 */
|
|
204
|
+
export async function deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome }) {
|
|
205
|
+
const projectRoot = resolveProjectSkillDir(projectDir, platform)
|
|
206
|
+
const duplicates = new Set()
|
|
207
|
+
if (!projectRoot || path.resolve(projectDir) === path.resolve(homeDir)) return duplicates
|
|
208
|
+
for (const skill of skills) {
|
|
209
|
+
const globalEntry = await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })
|
|
210
|
+
if (!globalEntry) continue
|
|
211
|
+
const dest = path.join(projectRoot, skill.name)
|
|
212
|
+
await removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
|
|
213
|
+
duplicates.add(skill.name)
|
|
214
|
+
logAction(`${platform}/${skill.name} 使用全局入口,移除项目重复入口`)
|
|
215
|
+
}
|
|
216
|
+
return duplicates
|
|
217
|
+
}
|
package/src/sync-opencode.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { chalk } from './utils.js'
|
|
4
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
4
5
|
import {
|
|
5
|
-
copyFilter,
|
|
6
6
|
logAction,
|
|
7
7
|
resolveSourceDir,
|
|
8
8
|
scanExisting,
|
|
@@ -15,8 +15,7 @@ async function copyItemsTo(baseDir, typeDir, items) {
|
|
|
15
15
|
|
|
16
16
|
for (const item of items) {
|
|
17
17
|
const dest = path.join(targetDir, item.name)
|
|
18
|
-
await
|
|
19
|
-
await fs.copy(item.sourcePath, dest, { overwrite: true, filter: copyFilter(item.sourcePath) })
|
|
18
|
+
await linkSkill({ skill: item, dest, backupRoot: path.join(baseDir, '..', '.dwy', 'skill-backups') })
|
|
20
19
|
logAction(`.opencode/${typeDir}/${item.name}`)
|
|
21
20
|
count++
|
|
22
21
|
}
|
|
@@ -31,7 +30,7 @@ async function removeUnselected(typeDir, existingNames, selectedNames, templateN
|
|
|
31
30
|
for (const name of existingNames) {
|
|
32
31
|
if (selectedNames.has(name)) continue
|
|
33
32
|
if (!templateNames.has(name)) continue
|
|
34
|
-
await
|
|
33
|
+
await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot: path.join(baseDir, '..', '.dwy', 'skill-backups') })
|
|
35
34
|
logAction(`.opencode/${typeDir}/${name}`, 'red', '×')
|
|
36
35
|
count++
|
|
37
36
|
}
|
|
@@ -45,6 +44,8 @@ export async function syncOpenCode({
|
|
|
45
44
|
projectDir,
|
|
46
45
|
global = false,
|
|
47
46
|
selected,
|
|
47
|
+
homeDir,
|
|
48
|
+
sharedSkills,
|
|
48
49
|
staleRemovals = {},
|
|
49
50
|
} = {}) {
|
|
50
51
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
@@ -74,7 +75,10 @@ export async function syncOpenCode({
|
|
|
74
75
|
let syncedCount = 0
|
|
75
76
|
let removedCount = 0
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
const skills = sharedSkills
|
|
79
|
+
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
80
|
+
: await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
81
|
+
syncedCount += await copyItemsTo(openCodeDir, 'skills', skills)
|
|
78
82
|
|
|
79
83
|
removedCount += await removeUnselected(
|
|
80
84
|
'skills',
|
package/src/sync-plan.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
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 { removeLegacyBaseline, syncCodex, syncGlobalBaselineDoc, syncProjectAgentsMd } from './sync-codex.js'
|
|
7
|
+
import { syncCursor } from './sync-cursor.js'
|
|
8
|
+
import { planMissingExternalSkills } from './skills-install.js'
|
|
9
|
+
|
|
10
|
+
/** 平台显示名,交互和变更清单共用。 */
|
|
11
|
+
export const PLATFORM_LABELS = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', cursor: 'Cursor' }
|
|
12
|
+
/** 项目范围的平台能力;全局范围用通用规范替代项目 Rules,且不支持 Cursor。 */
|
|
13
|
+
export const PLATFORM_SYNC_TYPES = {
|
|
14
|
+
claude: ['skills', 'rules', 'hooks'], codex: ['skills', 'rules', 'hooks'],
|
|
15
|
+
opencode: ['skills', 'rules'], cursor: ['rules'],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 只提供所选范围和平台支持的内容;未勾选类型不进入清理或状态更新。 */
|
|
19
|
+
export function contentOptions(scope, platforms) {
|
|
20
|
+
const supports = type => platforms.some(platform => PLATFORM_SYNC_TYPES[platform]?.includes(type))
|
|
21
|
+
return [
|
|
22
|
+
...(supports('skills') ? [{ value: 'skills', label: 'Skills', description: '管理当前范围的启用入口;已有共享内容不刷新' }] : []),
|
|
23
|
+
...(scope === 'project' && supports('rules') ? [{ value: 'rules', label: '项目规则(Rules)', description: '只更新当前项目的托管规则' }] : []),
|
|
24
|
+
...(scope === 'global' ? [{ value: 'baseline', label: '全局通用规范', description: '更新所选工具的原生全局指令入口,不修改项目规则' }] : []),
|
|
25
|
+
...(supports('hooks') ? [{ value: 'hooks', label: 'Hooks(按平台自动同步)', description: '仅选择此类型后,自动同步所选平台支持的 Hooks' }] : []),
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 为项目 Skills 标注平台覆盖;只有全部适用平台已全局提供时才禁止重复选择。 */
|
|
30
|
+
export async function projectSkillChoices(skills, platforms, { homeDir, codexHome }) {
|
|
31
|
+
const applicable = platforms.filter(platform => PLATFORM_SYNC_TYPES[platform]?.includes('skills'))
|
|
32
|
+
return Promise.all(skills.map(async skill => {
|
|
33
|
+
const covered = []
|
|
34
|
+
for (const platform of applicable) {
|
|
35
|
+
if (await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) covered.push(platform)
|
|
36
|
+
}
|
|
37
|
+
const disabled = applicable.length > 0 && covered.length === applicable.length
|
|
38
|
+
const missing = applicable.filter(platform => !covered.includes(platform))
|
|
39
|
+
const status = disabled ? '全局已启用' : covered.length ? `${missing.map(p => PLATFORM_LABELS[p]).join('、')} 可安装到项目` : ''
|
|
40
|
+
return { ...skill, disabled, label: status ? `${skill.name}(${status})` : skill.name,
|
|
41
|
+
description: [covered.length ? `全局已提供:${covered.map(p => PLATFORM_LABELS[p]).join('、')}` : '', skill.description].filter(Boolean).join(';') }
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const safeName = name => typeof name === 'string' && name.length > 0 && name !== '.' && name !== '..' && path.basename(name) === name && !/[\\/]/.test(name)
|
|
46
|
+
const cursorRuleName = name => `dwy-${name.replace(/\.md$/i, '')}.mdc`
|
|
47
|
+
|
|
48
|
+
async function existsEntry(dest) {
|
|
49
|
+
return fs.lstat(dest).then(() => true).catch(error => {
|
|
50
|
+
if (error.code === 'ENOENT') return false
|
|
51
|
+
throw error
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function validSkill(dest) {
|
|
56
|
+
const stat = await fs.stat(path.join(dest, 'SKILL.md')).catch(error => {
|
|
57
|
+
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
|
|
58
|
+
throw error
|
|
59
|
+
})
|
|
60
|
+
return Boolean(stat?.isFile())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ownedGlobalRoots(platform, homeDir, codexHome) {
|
|
64
|
+
if (platform === 'codex') return globalSkillRoots(platform, homeDir, codexHome)
|
|
65
|
+
return [resolveGlobalSkillDir(platform, homeDir)]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 只读计算本次全部操作和下一份状态;run 回调只在确认后执行。
|
|
70
|
+
* Skills 的入口与内容刷新分离;项目去重只删除项目入口,不改写全局提供者。
|
|
71
|
+
*/
|
|
72
|
+
export async function buildSyncPlan({
|
|
73
|
+
scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
|
|
74
|
+
projectState, globalState, staleRemovals = {}, staleEntries = {}, baseline,
|
|
75
|
+
skillScope = { destinations: ['project'], globalSkills: [] },
|
|
76
|
+
}) {
|
|
77
|
+
const global = scope === 'global'
|
|
78
|
+
const isHome = path.resolve(projectDir) === path.resolve(homeDir)
|
|
79
|
+
|| (await fs.pathExists(projectDir) && await fs.pathExists(homeDir)
|
|
80
|
+
&& await fs.realpath(projectDir) === await fs.realpath(homeDir))
|
|
81
|
+
if (!global && isHome) {
|
|
82
|
+
throw new Error('当前目录是用户主目录,请选择全局范围或进入项目目录')
|
|
83
|
+
}
|
|
84
|
+
const allowed = new Set(contentOptions(scope, platforms).map(option => option.value))
|
|
85
|
+
if (platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))
|
|
86
|
+
|| contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
|
|
87
|
+
const active = new Set(contentTypes)
|
|
88
|
+
if (active.has('baseline') && !baseline.trim()) throw new Error('没有可同步的全局通用规范')
|
|
89
|
+
const operations = []
|
|
90
|
+
const nextProject = structuredClone(projectState), nextGlobal = structuredClone(globalState)
|
|
91
|
+
const targetState = global ? nextGlobal : nextProject
|
|
92
|
+
const targetRoot = global ? homeDir : projectDir
|
|
93
|
+
const selectedSkillNames = new Set(selected.skills.map(item => item.name))
|
|
94
|
+
const skillSources = new Map(scans.skills.map(item => [item.name, item]))
|
|
95
|
+
const neededSkills = new Map()
|
|
96
|
+
const addedGlobalPaths = new Set(), removedGlobalPaths = new Set()
|
|
97
|
+
const localCleanup = new Set()
|
|
98
|
+
let projectChanged = !global && contentTypes.length > 0
|
|
99
|
+
const add = (kind, label, paths, run) => operations.push({ kind, label, paths, run })
|
|
100
|
+
const platformState = platform => targetState.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
|
|
101
|
+
const approved = (platform, type) => [...(staleRemovals[platform]?.[type] || [])].filter(safeName)
|
|
102
|
+
const retained = (platform, type) => (staleEntries[platform]?.[type] || []).filter(name => safeName(name) && !approved(platform, type).includes(name))
|
|
103
|
+
const backupRoot = path.join(targetRoot, '.dwy', 'skill-backups')
|
|
104
|
+
const providedToProject = async (name, platform) => globalSkillRoots(platform, homeDir, codexHome)
|
|
105
|
+
.some(root => addedGlobalPaths.has(path.join(root, name))) || Boolean(await findGlobalSkill({ name, platform, homeDir, codexHome }))
|
|
106
|
+
|
|
107
|
+
if (active.has('skills')) {
|
|
108
|
+
for (const destination of skillScope.destinations.filter(id => id !== 'project')) {
|
|
109
|
+
const root = resolveGlobalSkillDir(destination, homeDir)
|
|
110
|
+
if (!root) continue
|
|
111
|
+
for (const name of skillScope.globalSkills) {
|
|
112
|
+
const skill = skillSources.get(name)
|
|
113
|
+
if (!skill) continue
|
|
114
|
+
neededSkills.set(name, skill)
|
|
115
|
+
const dest = path.join(root, name), shared = { ...skill, sourcePath: path.join(homeDir, '.dwy', 'skills', name) }
|
|
116
|
+
add('link-skill', `显式脚本配置的额外全局入口:${name}`, [dest],
|
|
117
|
+
() => linkSkill({ skill: shared, dest, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') }))
|
|
118
|
+
addedGlobalPaths.add(dest)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('skills'))) {
|
|
122
|
+
if (!global && !skillScope.destinations.includes('project')) continue
|
|
123
|
+
const roots = global ? ownedGlobalRoots(platform, homeDir, codexHome) : [resolveProjectSkillDir(projectDir, platform)]
|
|
124
|
+
const managed = new Set([...skillSources.keys(), ...approved(platform, 'skills')])
|
|
125
|
+
const names = new Set([...selectedSkillNames, ...retained(platform, 'skills')])
|
|
126
|
+
const actualNames = new Set()
|
|
127
|
+
for (const skill of scans.skills) {
|
|
128
|
+
if (!global && await providedToProject(skill.name, platform)) continue
|
|
129
|
+
if (!names.has(skill.name)) continue
|
|
130
|
+
let dest = path.join(roots[0], skill.name)
|
|
131
|
+
if (global) {
|
|
132
|
+
for (const root of roots) {
|
|
133
|
+
if (await validSkill(path.join(root, skill.name))) { dest = path.join(root, skill.name); break }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
neededSkills.set(skill.name, skill)
|
|
137
|
+
const shared = { ...skill, sourcePath: path.join(homeDir, '.dwy', 'skills', skill.name) }
|
|
138
|
+
add('link-skill', `启用 ${PLATFORM_LABELS[platform]} Skill ${skill.name}(原真实目录先备份)`, [dest],
|
|
139
|
+
() => linkSkill({ skill: shared, dest, backupRoot }))
|
|
140
|
+
actualNames.add(skill.name)
|
|
141
|
+
if (global) addedGlobalPaths.add(dest)
|
|
142
|
+
}
|
|
143
|
+
for (const root of roots) {
|
|
144
|
+
if (!await fs.pathExists(root)) continue
|
|
145
|
+
for (const name of await fs.readdir(root)) {
|
|
146
|
+
if (!safeName(name) || !managed.has(name)) continue
|
|
147
|
+
const dest = path.join(root, name)
|
|
148
|
+
if (global ? names.has(name) : actualNames.has(name)) continue
|
|
149
|
+
const globallyProvided = !global && await providedToProject(name, platform)
|
|
150
|
+
add('remove-skill', `${globallyProvided ? '全局优先,清理项目重复入口' : '移除托管 Skill 入口'}:${name}(原真实目录先备份)`, [dest],
|
|
151
|
+
() => removeSkillEntry({ dest, backupRoot }))
|
|
152
|
+
if (global) removedGlobalPaths.add(dest)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
platformState(platform).skills = [...new Set([...actualNames, ...retained(platform, 'skills')])].sort()
|
|
156
|
+
}
|
|
157
|
+
if (global) {
|
|
158
|
+
for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('skills'))) {
|
|
159
|
+
const root = resolveProjectSkillDir(projectDir, platform)
|
|
160
|
+
if (isHome || !await fs.pathExists(root)) continue
|
|
161
|
+
for (const name of await fs.readdir(root)) {
|
|
162
|
+
if (!skillSources.has(name)) continue
|
|
163
|
+
let provided = false
|
|
164
|
+
for (const globalRoot of globalSkillRoots(platform, homeDir, codexHome)) {
|
|
165
|
+
const candidate = path.join(globalRoot, name)
|
|
166
|
+
if (addedGlobalPaths.has(candidate) || (!removedGlobalPaths.has(candidate) && await validSkill(candidate))) { provided = true; break }
|
|
167
|
+
}
|
|
168
|
+
const dest = path.join(root, name)
|
|
169
|
+
if (!provided || localCleanup.has(dest)) continue
|
|
170
|
+
localCleanup.add(dest)
|
|
171
|
+
add('remove-skill', `全局优先,清理当前项目重复入口:${name}(原真实目录先备份)`, [dest],
|
|
172
|
+
() => removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') }))
|
|
173
|
+
if (nextProject.platforms[platform]) {
|
|
174
|
+
nextProject.platforms[platform].skills = (nextProject.platforms[platform].skills || []).filter(item => item !== name)
|
|
175
|
+
projectChanged = true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (neededSkills.size) {
|
|
181
|
+
const skills = [...neededSkills.values()]
|
|
182
|
+
operations.unshift({ kind: 'ensure-skills', label: '准备缺失的共享副本;已有内容不刷新',
|
|
183
|
+
paths: [...skills.map(skill => path.join(homeDir, '.dwy', 'skills', skill.name)), path.join(homeDir, '.dwy', 'skill-store.json')],
|
|
184
|
+
run: () => ensureSharedSkills({ skills, homeDir }) })
|
|
185
|
+
}
|
|
186
|
+
if ([...neededSkills.values()].some(skill => skill.category === '产品0到1')) {
|
|
187
|
+
const target = path.join(process.env.DWY_HOME || path.join(homeDir, '.dwy'), 'skills')
|
|
188
|
+
const external = await planMissingExternalSkills(target)
|
|
189
|
+
if (external.paths.length) add('external-skills', '补齐产品流程外部 Skills(联网拉取固定版本,保留已有内容)', external.paths, external.run)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const cliPlatforms = platforms.filter(platform => platform !== 'cursor')
|
|
194
|
+
if (active.has('rules')) {
|
|
195
|
+
const selectedNames = new Set(selected.rules.map(item => item.name))
|
|
196
|
+
const rules = scans.rules.filter(rule => selectedNames.has(rule.name))
|
|
197
|
+
const preserved = new Set(cliPlatforms.flatMap(platform => retained(platform, 'rules')))
|
|
198
|
+
if (cliPlatforms.length) {
|
|
199
|
+
add('rules', '更新项目 Rules(保留手写内容)', [path.join(projectDir, 'AGENTS.md')],
|
|
200
|
+
() => syncProjectAgentsMd(projectDir, rules, { preservedRuleNames: preserved }))
|
|
201
|
+
const names = [...new Set([...rules.map(rule => rule.name), ...preserved])].sort()
|
|
202
|
+
for (const platform of ['claude', 'codex', 'opencode']) {
|
|
203
|
+
if (platforms.includes(platform) || nextProject.platforms[platform]) {
|
|
204
|
+
nextProject.platforms[platform] ||= { skills: [], rules: [], hooks: [], commands: [] }
|
|
205
|
+
nextProject.platforms[platform].rules = names
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (platforms.includes('cursor')) {
|
|
210
|
+
const paths = rules.map(rule => path.join(projectDir, '.cursor', 'rules', cursorRuleName(rule.name)))
|
|
211
|
+
const root = path.join(projectDir, '.cursor', 'rules')
|
|
212
|
+
const managed = new Set([...scans.rules.map(rule => cursorRuleName(rule.name)), ...approved('cursor', 'rules').map(cursorRuleName), '00-dwy-global.mdc'])
|
|
213
|
+
if (await fs.pathExists(root)) for (const name of await fs.readdir(root)) if (managed.has(name)) paths.push(path.join(root, name))
|
|
214
|
+
add('rules', '同步 Cursor 项目 Rules', [...new Set(paths)], () => syncCursor({ sourceDir, projectDir, selected: { rules }, staleRemovals: staleRemovals.cursor }))
|
|
215
|
+
platformState('cursor').rules = [...new Set([...rules.map(rule => rule.name), ...retained('cursor', 'rules')])].sort()
|
|
216
|
+
}
|
|
217
|
+
if (platforms.includes('claude')) {
|
|
218
|
+
const names = new Set([...scans.rules.map(rule => rule.name), ...approved('claude', 'rules')])
|
|
219
|
+
for (const name of names) {
|
|
220
|
+
const dest = path.join(projectDir, '.claude', 'rules', name)
|
|
221
|
+
if (safeName(name) && await existsEntry(dest)) add('legacy-rule', '清理旧项目规则入口', [dest], () => fs.remove(dest))
|
|
222
|
+
}
|
|
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
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (active.has('baseline')) {
|
|
235
|
+
const paths = { claude: path.join(homeDir, '.claude', 'CLAUDE.md'), codex: path.join(codexHome, 'AGENTS.md'), opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md') }
|
|
236
|
+
for (const platform of cliPlatforms) {
|
|
237
|
+
const dest = paths[platform]
|
|
238
|
+
add('baseline', `更新 ${PLATFORM_LABELS[platform]} 全局通用规范(保留手写内容)`, [dest], () => syncGlobalBaselineDoc(baseline, dest))
|
|
239
|
+
platformState(platform).rules = []
|
|
240
|
+
}
|
|
241
|
+
if (platforms.includes('claude')) {
|
|
242
|
+
const dest = path.join(homeDir, '.claude', 'AGENTS.md')
|
|
243
|
+
if (await fs.pathExists(dest)) {
|
|
244
|
+
const text = await fs.readFile(dest, 'utf8')
|
|
245
|
+
if (text.trim() === baseline.trim() || /<!-- DWY-BASELINE:START[\s\S]*?DWY-BASELINE:END -->/.test(text)) {
|
|
246
|
+
add('legacy-baseline', '清理旧全局基线托管内容', [dest], () => removeLegacyBaseline(baseline, dest))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (active.has('hooks')) {
|
|
252
|
+
const manifests = await loadHookManifests(sourceDir)
|
|
253
|
+
for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('hooks'))) {
|
|
254
|
+
const hooks = scans.hooks.filter(hook => manifests.some(manifest => manifest.name === hook.name && (!manifest.platforms || manifest.platforms.includes(platform))))
|
|
255
|
+
const root = global && platform === 'codex' ? codexHome : path.join(targetRoot, platform === 'claude' ? '.claude' : '.codex')
|
|
256
|
+
const paths = hooks.map(hook => path.join(root, 'hooks', hook.name))
|
|
257
|
+
const selectedNames = new Set(hooks.map(hook => hook.name))
|
|
258
|
+
const managedNames = new Set([...scans.hooks.map(hook => hook.name), ...approved(platform, 'hooks')])
|
|
259
|
+
const removedNames = []
|
|
260
|
+
const hookDir = path.join(root, 'hooks')
|
|
261
|
+
if (await fs.pathExists(hookDir)) {
|
|
262
|
+
for (const name of await fs.readdir(hookDir)) {
|
|
263
|
+
if (managedNames.has(name) && !selectedNames.has(name)) {
|
|
264
|
+
paths.push(path.join(hookDir, name))
|
|
265
|
+
removedNames.push(name)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
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
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { operations, nextProject, nextGlobal, projectChanged, globalChanged: global && contentTypes.length > 0 }
|
|
278
|
+
}
|