create-dwy 0.22.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 +12 -0
- package/package.json +1 -1
- package/src/cli-help.js +20 -11
- package/src/skill-store.js +15 -0
- package/src/skills-install.js +47 -11
- package/src/sync-all.js +167 -658
- package/src/sync-codex.js +38 -25
- package/src/sync-global-skills.js +4 -5
- package/src/sync-opencode.js +2 -2
- package/src/sync-plan.js +278 -0
- package/src/sync.js +25 -19
- package/src/tab-multiselect.js +7 -5
package/src/sync-codex.js
CHANGED
|
@@ -1,7 +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,
|
|
4
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
5
5
|
import {
|
|
6
6
|
extractDescription,
|
|
7
7
|
loadHookManifests,
|
|
@@ -220,16 +220,16 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
|
|
|
220
220
|
|
|
221
221
|
// ---------- hooks → .codex/ ----------
|
|
222
222
|
|
|
223
|
-
export async function scanExistingCodexHooks(proj) {
|
|
224
|
-
const dir = path.join(
|
|
223
|
+
export async function scanExistingCodexHooks(proj, configDir = path.join(proj, '.codex')) {
|
|
224
|
+
const dir = path.join(configDir, 'hooks')
|
|
225
225
|
if (!await fs.pathExists(dir)) return new Set()
|
|
226
226
|
const entries = await fs.readdir(dir)
|
|
227
227
|
return new Set(entries.filter(n => n !== '.gitkeep' && n !== '.DS_Store'))
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
async function copyCodexHooks(hooks,
|
|
230
|
+
async function copyCodexHooks(hooks, configDir) {
|
|
231
231
|
if (hooks.length === 0) return 0
|
|
232
|
-
const dir = path.join(
|
|
232
|
+
const dir = path.join(configDir, 'hooks')
|
|
233
233
|
await fs.ensureDir(dir)
|
|
234
234
|
for (const h of hooks) {
|
|
235
235
|
if (!await fs.pathExists(h.sourcePath)) {
|
|
@@ -248,7 +248,12 @@ async function copyCodexHooks(hooks, proj) {
|
|
|
248
248
|
return hooks.length
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
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) {
|
|
252
257
|
const hooksJson = { hooks: {} }
|
|
253
258
|
const grouped = new Map()
|
|
254
259
|
|
|
@@ -263,7 +268,7 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
|
|
|
263
268
|
hooks.push({
|
|
264
269
|
type: 'command',
|
|
265
270
|
command: global
|
|
266
|
-
?
|
|
271
|
+
? globalHookCommand(configDir, manifest.name)
|
|
267
272
|
: `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
|
|
268
273
|
...(manifest.timeout ? { timeout: manifest.timeout } : {}),
|
|
269
274
|
})
|
|
@@ -279,13 +284,14 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
|
|
|
279
284
|
return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
|
|
280
285
|
}
|
|
281
286
|
|
|
282
|
-
function isManagedCodexHookCommand(command, managedHookNames) {
|
|
287
|
+
function isManagedCodexHookCommand(command, managedHookNames, configDir) {
|
|
283
288
|
if (typeof command !== 'string') return false
|
|
284
289
|
const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
|
|
285
290
|
return !!(match && managedHookNames.has(match[1]))
|
|
291
|
+
|| [...managedHookNames].some(name => command === globalHookCommand(configDir, name))
|
|
286
292
|
}
|
|
287
293
|
|
|
288
|
-
function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
294
|
+
function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
|
|
289
295
|
const merged = {}
|
|
290
296
|
const sourceEvents = new Set([
|
|
291
297
|
...Object.keys(existingHooks || {}),
|
|
@@ -296,7 +302,7 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
|
296
302
|
const kept = []
|
|
297
303
|
|
|
298
304
|
for (const cfg of existingHooks?.[event] || []) {
|
|
299
|
-
const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames))
|
|
305
|
+
const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames, configDir))
|
|
300
306
|
if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -307,10 +313,10 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
|
307
313
|
return Object.keys(merged).length > 0 ? merged : undefined
|
|
308
314
|
}
|
|
309
315
|
|
|
310
|
-
async function syncCodexHooksJson(sourceDir,
|
|
311
|
-
const dest = path.join(
|
|
316
|
+
async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global) {
|
|
317
|
+
const dest = path.join(configDir, 'hooks.json')
|
|
312
318
|
const manifests = await loadHookManifests(sourceDir)
|
|
313
|
-
const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
|
|
319
|
+
const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
|
|
314
320
|
const managedHookNames = new Set(
|
|
315
321
|
manifests
|
|
316
322
|
.filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
|
|
@@ -320,7 +326,7 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
|
|
|
320
326
|
let nextJson
|
|
321
327
|
if (await fs.pathExists(dest)) {
|
|
322
328
|
const existing = await fs.readJson(dest)
|
|
323
|
-
const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames)
|
|
329
|
+
const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames, configDir)
|
|
324
330
|
if (!nextHooks) {
|
|
325
331
|
if (!existing.hooks) return 0
|
|
326
332
|
const { hooks, ...rest } = existing
|
|
@@ -344,14 +350,16 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
|
|
|
344
350
|
return 1
|
|
345
351
|
}
|
|
346
352
|
|
|
347
|
-
/** 同步 Codex
|
|
353
|
+
/** 同步 Codex Skills 与 Hooks;全局 Hooks 遵循 codexHome。 */
|
|
348
354
|
export async function syncCodex({
|
|
349
355
|
sourceDir: sourceDirOverride,
|
|
350
356
|
projectDir,
|
|
351
357
|
global = false,
|
|
352
358
|
selected,
|
|
353
359
|
homeDir,
|
|
360
|
+
codexHome,
|
|
354
361
|
sharedSkills,
|
|
362
|
+
syncTypes = ['skills', 'hooks'],
|
|
355
363
|
staleRemovals = {},
|
|
356
364
|
} = {}) {
|
|
357
365
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
@@ -360,6 +368,7 @@ export async function syncCodex({
|
|
|
360
368
|
}
|
|
361
369
|
|
|
362
370
|
const proj = projectDir || process.cwd()
|
|
371
|
+
const configDir = global && codexHome ? codexHome : path.join(proj, '.codex')
|
|
363
372
|
|
|
364
373
|
console.log(chalk.blue('\nScanning available config for Codex...\n'))
|
|
365
374
|
|
|
@@ -371,7 +380,7 @@ export async function syncCodex({
|
|
|
371
380
|
|
|
372
381
|
const existing = {
|
|
373
382
|
skills: await scanExistingCodexSkills(proj),
|
|
374
|
-
hooks: await scanExistingCodexHooks(proj),
|
|
383
|
+
hooks: await scanExistingCodexHooks(proj, configDir),
|
|
375
384
|
}
|
|
376
385
|
const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
|
|
377
386
|
const syncedSelection = {
|
|
@@ -385,12 +394,16 @@ export async function syncCodex({
|
|
|
385
394
|
let syncedCount = 0
|
|
386
395
|
let removedCount = 0
|
|
387
396
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
}
|
|
394
407
|
|
|
395
408
|
const skillTemplateNames = new Set([
|
|
396
409
|
...scans.skills.map(s => s.name),
|
|
@@ -400,10 +413,10 @@ export async function syncCodex({
|
|
|
400
413
|
...scans.hooks.map(h => h.name),
|
|
401
414
|
...(staleRemovals.hooks || new Set()),
|
|
402
415
|
])
|
|
403
|
-
removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
|
|
404
|
-
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)
|
|
405
418
|
|
|
406
419
|
console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
|
|
407
420
|
console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
|
|
408
|
-
console.log(chalk.gray(` Hooks → ${
|
|
421
|
+
console.log(chalk.gray(` Hooks → ${configDir}`))
|
|
409
422
|
}
|
|
@@ -7,7 +7,7 @@ import fs from 'fs-extra'
|
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'path'
|
|
9
9
|
import { logAction, scanExisting } from './sync.js'
|
|
10
|
-
import { linkSkill,
|
|
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'
|
|
@@ -123,7 +123,7 @@ 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
|
|
126
|
+
const sharedSkills = await ensureSharedSkills({ skills, homeDir })
|
|
127
127
|
|
|
128
128
|
for (const destId of destIds) {
|
|
129
129
|
const destRoot = resolveGlobalSkillDir(destId, homeDir)
|
|
@@ -149,7 +149,7 @@ export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.hom
|
|
|
149
149
|
*/
|
|
150
150
|
export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills, homeDir }) {
|
|
151
151
|
let count = 0
|
|
152
|
-
const sharedSkills = await
|
|
152
|
+
const sharedSkills = await ensureSharedSkills({ skills, homeDir })
|
|
153
153
|
|
|
154
154
|
for (const platform of platforms) {
|
|
155
155
|
const destRoot = resolveProjectSkillDir(projectDir, platform)
|
|
@@ -200,7 +200,7 @@ export async function scanGlobalSkills({ homeDir, codexHome }) {
|
|
|
200
200
|
return names
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
203
|
+
/** 全局优先:仅备份并移除项目重复入口,不改写全局提供者,返回去重名称。 */
|
|
204
204
|
export async function deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome }) {
|
|
205
205
|
const projectRoot = resolveProjectSkillDir(projectDir, platform)
|
|
206
206
|
const duplicates = new Set()
|
|
@@ -208,7 +208,6 @@ export async function deduplicateProjectSkills({ projectDir, platform, skills, h
|
|
|
208
208
|
for (const skill of skills) {
|
|
209
209
|
const globalEntry = await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })
|
|
210
210
|
if (!globalEntry) continue
|
|
211
|
-
await linkSkill({ skill, dest: globalEntry, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') })
|
|
212
211
|
const dest = path.join(projectRoot, skill.name)
|
|
213
212
|
await removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
|
|
214
213
|
duplicates.add(skill.name)
|
package/src/sync-opencode.js
CHANGED
|
@@ -1,7 +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,
|
|
4
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
5
5
|
import {
|
|
6
6
|
logAction,
|
|
7
7
|
resolveSourceDir,
|
|
@@ -77,7 +77,7 @@ export async function syncOpenCode({
|
|
|
77
77
|
|
|
78
78
|
const skills = sharedSkills
|
|
79
79
|
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
80
|
-
: await
|
|
80
|
+
: await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
81
81
|
syncedCount += await copyItemsTo(openCodeDir, 'skills', skills)
|
|
82
82
|
|
|
83
83
|
removedCount += await removeUnselected(
|
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
|
+
}
|
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,
|
|
@@ -406,6 +407,7 @@ export async function syncClaude({
|
|
|
406
407
|
selected,
|
|
407
408
|
homeDir,
|
|
408
409
|
sharedSkills,
|
|
410
|
+
syncTypes = ['skills', 'rules', 'hooks'],
|
|
409
411
|
staleRemovals = {},
|
|
410
412
|
} = {}) {
|
|
411
413
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
@@ -461,19 +463,23 @@ export async function syncClaude({
|
|
|
461
463
|
let syncedCount = 0
|
|
462
464
|
let removedCount = 0
|
|
463
465
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
466
|
+
if (syncTypes.includes('skills')) {
|
|
467
|
+
const skills = sharedSkills
|
|
468
|
+
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
469
|
+
: await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
470
|
+
syncedCount += await copyItems(skills, projectTargetDir)
|
|
471
|
+
}
|
|
472
|
+
if (syncTypes.includes('hooks')) {
|
|
473
|
+
syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global)
|
|
474
|
+
syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
|
|
475
|
+
}
|
|
470
476
|
|
|
471
477
|
const skillTemplateNames = new Set([...scans.skills.map(s => s.name), ...approvedStaleSkills])
|
|
472
478
|
const ruleTemplateNames = new Set([...scans.rules.map(r => r.name), ...approvedStaleRules])
|
|
473
479
|
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)
|
|
480
|
+
if (syncTypes.includes('skills')) removedCount += await removeUnselected('skills', existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, projectTargetDir)
|
|
481
|
+
if (syncTypes.includes('rules')) removedCount += await removeUnselected('rules', existing.rules, new Set(), ruleTemplateNames, projectTargetDir)
|
|
482
|
+
if (syncTypes.includes('hooks')) removedCount += await removeUnselected('hooks', existing.hooks, selectedHookNames, hookTemplateNames, projectTargetDir)
|
|
477
483
|
|
|
478
484
|
console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
|
|
479
485
|
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
|
|