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/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, prepareSharedSkills, removeSkillEntry } from './skill-store.js'
4
+ import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
5
5
  import {
6
6
  extractDescription,
7
7
  loadHookManifests,
@@ -111,19 +111,30 @@ function upsertBaselineBlock(content, baselineBody) {
111
111
  return base ? `${block}\n\n${base}` : `${block}\n`
112
112
  }
113
113
 
114
- /** 更新 CLI 全局基线,仅替换 dwy 托管块,并清除旧版写入的项目 Rules 托管块。 */
115
- export async function syncGlobalBaselineDoc(baseline, dest) {
116
- if (!baseline.trim()) return 0
114
+ /** 判断文件是否含有有效的 dwy 通用规范;个人文件不作为继承依据。 */
115
+ export async function hasManagedBaseline(dest) {
116
+ if (!await fs.pathExists(dest)) return false
117
+ const match = (await fs.readFile(dest, 'utf8')).match(BASELINE_RE)
118
+ return Boolean(match && match[0].replace(/<!--[\s\S]*?-->/g, '').trim())
119
+ }
120
+
121
+ /** 独立更新通用规范,空内容移除基线块;保留 Rules 和个人内容。 */
122
+ export async function syncBaselineDoc(baseline, dest) {
117
123
  const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
118
- const previous = current.trim() === baseline.trim() ? '' : current
119
- const next = upsertManagedBlock(upsertBaselineBlock(previous, baseline), '')
124
+ const previous = baseline.trim() && current.trim() === baseline.trim() ? '' : current
125
+ const next = upsertBaselineBlock(previous, baseline)
120
126
  if (next === current) return 0
121
- await fs.ensureDir(path.dirname(dest))
122
- await fs.writeFile(dest, next)
123
- logAction(`global baseline → ${dest}`)
127
+ if (next.trim()) {
128
+ await fs.ensureDir(path.dirname(dest))
129
+ await fs.writeFile(dest, next)
130
+ } else await fs.remove(dest)
131
+ logAction(`baseline → ${dest}`)
124
132
  return 1
125
133
  }
126
134
 
135
+ /** 兼容旧调用方;全局基线更新同样保留其他托管块。 */
136
+ export const syncGlobalBaselineDoc = syncBaselineDoc
137
+
127
138
  /** 迁移旧入口中的基线;无托管标记时只移除与当前模板完全一致的文件。 */
128
139
  export async function removeLegacyBaseline(baseline, dest) {
129
140
  if (!await fs.pathExists(dest)) return
@@ -153,7 +164,7 @@ export async function syncProjectAgentsMd(projectDir, selectedRules, {
153
164
  }
154
165
  const blockBody = sections.join('\n\n---\n\n')
155
166
 
156
- const next = upsertManagedBlock(upsertBaselineBlock(current, ''), blockBody)
167
+ const next = upsertManagedBlock(current, blockBody)
157
168
  if (next === current) return 0
158
169
  await fs.ensureDir(path.dirname(dest))
159
170
  await fs.writeFile(dest, next)
@@ -220,16 +231,16 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
220
231
 
221
232
  // ---------- hooks → .codex/ ----------
222
233
 
223
- export async function scanExistingCodexHooks(proj) {
224
- const dir = path.join(proj, '.codex', 'hooks')
234
+ export async function scanExistingCodexHooks(proj, configDir = path.join(proj, '.codex')) {
235
+ const dir = path.join(configDir, 'hooks')
225
236
  if (!await fs.pathExists(dir)) return new Set()
226
237
  const entries = await fs.readdir(dir)
227
238
  return new Set(entries.filter(n => n !== '.gitkeep' && n !== '.DS_Store'))
228
239
  }
229
240
 
230
- async function copyCodexHooks(hooks, proj) {
241
+ async function copyCodexHooks(hooks, configDir) {
231
242
  if (hooks.length === 0) return 0
232
- const dir = path.join(proj, '.codex', 'hooks')
243
+ const dir = path.join(configDir, 'hooks')
233
244
  await fs.ensureDir(dir)
234
245
  for (const h of hooks) {
235
246
  if (!await fs.pathExists(h.sourcePath)) {
@@ -248,7 +259,13 @@ async function copyCodexHooks(hooks, proj) {
248
259
  return hooks.length
249
260
  }
250
261
 
251
- function buildCodexHooksJson(manifests, selectedHookNames, global) {
262
+ function globalHookCommand(configDir, name) {
263
+ const script = path.join(configDir, 'hooks', name).replace(/\\/g, '/')
264
+ return `bash '${script.replace(/'/g, "'\\''")}'`
265
+ }
266
+
267
+ /** 生成 Codex Hook 注册,继承检测和写入共用相同命令格式。 */
268
+ export function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
252
269
  const hooksJson = { hooks: {} }
253
270
  const grouped = new Map()
254
271
 
@@ -263,7 +280,7 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
263
280
  hooks.push({
264
281
  type: 'command',
265
282
  command: global
266
- ? `bash "$HOME/.codex/hooks/${manifest.name}"`
283
+ ? globalHookCommand(configDir, manifest.name)
267
284
  : `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
268
285
  ...(manifest.timeout ? { timeout: manifest.timeout } : {}),
269
286
  })
@@ -279,13 +296,19 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
279
296
  return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
280
297
  }
281
298
 
282
- function isManagedCodexHookCommand(command, managedHookNames) {
299
+ /** 只识别完整生成命令及已知历史格式,保留同名个人路径。 */
300
+ export function isManagedCodexHookCommand(command, managedHookNames, configDir) {
283
301
  if (typeof command !== 'string') return false
284
- const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
285
- return !!(match && managedHookNames.has(match[1]))
302
+ return [...managedHookNames].some(name => [
303
+ globalHookCommand(configDir, name),
304
+ `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${name}"`,
305
+ `bash "$HOME/.codex/hooks/${name}"`,
306
+ `bash ~/.codex/hooks/${name}`,
307
+ ].includes(command))
286
308
  }
287
309
 
288
- function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
310
+ /** 只替换指定托管注册,保留其他命令及配置字段。 */
311
+ export function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
289
312
  const merged = {}
290
313
  const sourceEvents = new Set([
291
314
  ...Object.keys(existingHooks || {}),
@@ -296,7 +319,7 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
296
319
  const kept = []
297
320
 
298
321
  for (const cfg of existingHooks?.[event] || []) {
299
- const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames))
322
+ const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames, configDir))
300
323
  if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
301
324
  }
302
325
 
@@ -307,20 +330,18 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
307
330
  return Object.keys(merged).length > 0 ? merged : undefined
308
331
  }
309
332
 
310
- async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
311
- const dest = path.join(proj, '.codex', 'hooks.json')
333
+ async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleHookNames) {
334
+ const dest = path.join(configDir, 'hooks.json')
312
335
  const manifests = await loadHookManifests(sourceDir)
313
- const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
336
+ const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
314
337
  const managedHookNames = new Set(
315
- manifests
316
- .filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
317
- .map(manifest => manifest.name),
338
+ [...manifests.map(manifest => manifest.name), ...staleHookNames],
318
339
  )
319
340
 
320
341
  let nextJson
321
342
  if (await fs.pathExists(dest)) {
322
343
  const existing = await fs.readJson(dest)
323
- const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames)
344
+ const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames, configDir)
324
345
  if (!nextHooks) {
325
346
  if (!existing.hooks) return 0
326
347
  const { hooks, ...rest } = existing
@@ -344,14 +365,16 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
344
365
  return 1
345
366
  }
346
367
 
347
- /** 同步 Codex 配置;global 模式将规则写入用户 .codex/AGENTS.md。 */
368
+ /** 同步 Codex Skills 与 Hooks;全局 Hooks 遵循 codexHome。 */
348
369
  export async function syncCodex({
349
370
  sourceDir: sourceDirOverride,
350
371
  projectDir,
351
372
  global = false,
352
373
  selected,
353
374
  homeDir,
375
+ codexHome,
354
376
  sharedSkills,
377
+ syncTypes = ['skills', 'hooks'],
355
378
  staleRemovals = {},
356
379
  } = {}) {
357
380
  const sourceDir = sourceDirOverride || await resolveSourceDir()
@@ -360,6 +383,7 @@ export async function syncCodex({
360
383
  }
361
384
 
362
385
  const proj = projectDir || process.cwd()
386
+ const configDir = global && codexHome ? codexHome : path.join(proj, '.codex')
363
387
 
364
388
  console.log(chalk.blue('\nScanning available config for Codex...\n'))
365
389
 
@@ -371,7 +395,7 @@ export async function syncCodex({
371
395
 
372
396
  const existing = {
373
397
  skills: await scanExistingCodexSkills(proj),
374
- hooks: await scanExistingCodexHooks(proj),
398
+ hooks: await scanExistingCodexHooks(proj, configDir),
375
399
  }
376
400
  const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
377
401
  const syncedSelection = {
@@ -385,12 +409,16 @@ export async function syncCodex({
385
409
  let syncedCount = 0
386
410
  let removedCount = 0
387
411
 
388
- const skills = sharedSkills
389
- ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
390
- : await prepareSharedSkills({ skills: syncedSelection.skills, homeDir })
391
- syncedCount += await copySkillsFlat(skills, proj)
392
- syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
393
- syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames, global)
412
+ if (syncTypes.includes('skills')) {
413
+ const skills = sharedSkills
414
+ ? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
415
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
416
+ syncedCount += await copySkillsFlat(skills, proj)
417
+ }
418
+ if (syncTypes.includes('hooks')) {
419
+ syncedCount += await copyCodexHooks(syncedSelection.hooks, configDir)
420
+ syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleRemovals.hooks || [])
421
+ }
394
422
 
395
423
  const skillTemplateNames = new Set([
396
424
  ...scans.skills.map(s => s.name),
@@ -400,10 +428,10 @@ export async function syncCodex({
400
428
  ...scans.hooks.map(h => h.name),
401
429
  ...(staleRemovals.hooks || new Set()),
402
430
  ])
403
- removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
404
- removedCount += await removeUnselectedFlat(['.codex', 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
431
+ if (syncTypes.includes('skills')) removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
432
+ if (syncTypes.includes('hooks')) removedCount += await removeUnselectedFlat([path.relative(proj, configDir), 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
405
433
 
406
434
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
407
435
  console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
408
- console.log(chalk.gray(` Hooks → ${path.join(proj, '.codex')}`))
436
+ console.log(chalk.gray(` Hooks → ${configDir}`))
409
437
  }
@@ -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, prepareSharedSkills, removeSkillEntry } from './skill-store.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'
@@ -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 prepareSharedSkills({ skills, homeDir })
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 prepareSharedSkills({ skills, homeDir })
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)
@@ -0,0 +1,119 @@
1
+ import fs from 'fs-extra'
2
+ import path from 'node:path'
3
+ import { buildClaudeHooksSettings, isManagedClaudeHookCommand, mergeClaudeHooks, loadHookManifests, scanHooks } from './sync.js'
4
+ import { buildCodexHooksJson, hasManagedBaseline, isManagedCodexHookCommand, mergeCodexHooks } from './sync-codex.js'
5
+
6
+ /** Hook 配置文件名由平台决定,Codex 根目录由调用方解析。 */
7
+ export function hookConfigFile(platform, root) {
8
+ return path.join(root, platform === 'claude' ? 'settings.json' : 'hooks.json')
9
+ }
10
+
11
+ async function readHookConfig(platform, root) {
12
+ const file = hookConfigFile(platform, root)
13
+ if (!await fs.pathExists(file)) return {}
14
+ const config = await fs.readJson(file)
15
+ if (!config || typeof config !== 'object' || Array.isArray(config)
16
+ || (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))) {
17
+ throw new Error(`Hook 配置格式无效:${file}`)
18
+ }
19
+ for (const configs of Object.values(config.hooks || {})) {
20
+ if (!Array.isArray(configs) || configs.some(cfg => !cfg || typeof cfg !== 'object' || !Array.isArray(cfg.hooks)
21
+ || cfg.hooks.some(hook => !hook || typeof hook !== 'object'))) throw new Error(`Hook 配置格式无效:${file}`)
22
+ }
23
+ return config
24
+ }
25
+
26
+ /** 查找待移除名称的托管注册;不要求脚本仍在,便于清理坏配置。 */
27
+ export async function hasManagedHookConfig(platform, root, names) {
28
+ const config = await readHookConfig(platform, root)
29
+ const matches = platform === 'claude' ? isManagedClaudeHookCommand : isManagedCodexHookCommand
30
+ return Object.values(config.hooks || {}).some(configs => configs.some(cfg => cfg.hooks.some(hook => matches(hook.command, names, root))))
31
+ }
32
+
33
+ /** 不隐式改变用户的全局禁用开关;启用前明确报告冲突。 */
34
+ export async function assertHooksEnabled(platform, root) {
35
+ if ((await readHookConfig(platform, root)).disableAllHooks === true) throw new Error(`Hooks 被 disableAllHooks 禁用,无法启用:${hookConfigFile(platform, root)}`)
36
+ }
37
+
38
+ /** 只移除指定托管脚本和注册,不补装或更新其他 Hooks。 */
39
+ export async function removeManagedHooks(platform, root, names, paths) {
40
+ const file = hookConfigFile(platform, root)
41
+ if (paths.includes(file)) {
42
+ const config = await readHookConfig(platform, root)
43
+ const merge = platform === 'claude' ? mergeClaudeHooks : mergeCodexHooks
44
+ const nextHooks = merge(config.hooks, undefined, names, root)
45
+ const next = { ...config }
46
+ if (nextHooks) next.hooks = nextHooks
47
+ else delete next.hooks
48
+ if (JSON.stringify(config) !== JSON.stringify(next)) {
49
+ if (Object.keys(next).length) await fs.writeJson(file, next, { spaces: 2 })
50
+ else await fs.remove(file)
51
+ }
52
+ }
53
+ for (const dest of paths) if (dest !== file) await fs.remove(dest)
54
+ }
55
+
56
+ /** 解析通用规范的原生全局入口;Cursor 没有文件入口。 */
57
+ export function globalBaselinePath(platform, homeDir, codexHome) {
58
+ return { claude: path.join(homeDir, '.claude', 'CLAUDE.md'), codex: path.join(codexHome, 'AGENTS.md'),
59
+ opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md') }[platform]
60
+ }
61
+
62
+ /** 返回所选 CLI 中实际提供 dwy 全局通用规范的平台。 */
63
+ export async function baselineProviders(platforms, { homeDir, codexHome }) {
64
+ const providers = []
65
+ for (const platform of platforms) {
66
+ const dest = globalBaselinePath(platform, homeDir, codexHome)
67
+ if (dest && await hasManagedBaseline(dest)) providers.push(platform)
68
+ }
69
+ return providers
70
+ }
71
+
72
+ /** 全局 Hook 必须在对应事件及 matcher 下注册了完整执行命令,且脚本仍有效。 */
73
+ export async function globalHookNames({ platform, sourceDir, homeDir, codexHome }) {
74
+ if (!['claude', 'codex'].includes(platform)) return new Set()
75
+ const root = platform === 'claude' ? path.join(homeDir, '.claude') : codexHome
76
+ const config = await readHookConfig(platform, root)
77
+ if (config.disableAllHooks === true) return new Set()
78
+ const manifests = await loadHookManifests(sourceDir)
79
+ const result = new Set()
80
+ for (const hook of await scanHooks(sourceDir)) {
81
+ const names = new Set([hook.name])
82
+ const generated = platform === 'claude' ? buildClaudeHooksSettings(manifests, names, true)
83
+ : buildCodexHooksJson(manifests, names, true, root)
84
+ const registrations = Object.entries(generated.hooks || {}).flatMap(([event, configs]) =>
85
+ configs.flatMap(cfg => cfg.hooks.map(command => ({ event, matcher: cfg.matcher, command: command.command }))))
86
+ if (registrations.length === 0) continue
87
+ const registered = registrations.every(expected => {
88
+ const configs = config.hooks?.[expected.event] || []
89
+ return configs.some(cfg => cfg.matcher === expected.matcher && Array.isArray(cfg.hooks)
90
+ && cfg.hooks.some(item => item.type === 'command' && item.command === expected.command))
91
+ })
92
+ if (!registered) continue
93
+ const stat = await fs.stat(path.join(root, 'hooks', hook.name)).catch(error => {
94
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
95
+ throw error
96
+ })
97
+ if (stat?.isFile()) result.add(hook.name)
98
+ }
99
+ return result
100
+ }
101
+
102
+ /** 配置写入与清理不能穿透边界内的目录或文件链接;确认前及执行时均检查。 */
103
+ export async function assertConfigPath(dest, root) {
104
+ const nearest = async start => {
105
+ let current = start
106
+ while (!await fs.lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error })) current = path.dirname(current)
107
+ return current
108
+ }
109
+ const base = await nearest(root)
110
+ const expectedRoot = path.resolve(await fs.realpath(base), path.relative(base, root))
111
+ const entry = await nearest(dest)
112
+ const expected = path.resolve(expectedRoot, path.relative(root, entry))
113
+ let actual
114
+ try { actual = await fs.realpath(entry) } catch (error) {
115
+ if (!['ENOENT', 'ELOOP', 'ENOTDIR'].includes(error.code)) throw error
116
+ throw new Error(`配置入口为无效链接,保留原入口:${entry}`)
117
+ }
118
+ if (actual !== expected) throw new Error(`配置入口链接指向外部位置,保留原入口:${entry}`)
119
+ }
@@ -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, prepareSharedSkills, removeSkillEntry } from './skill-store.js'
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 prepareSharedSkills({ skills: syncedSelection.skills, homeDir })
80
+ : await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
81
81
  syncedCount += await copyItemsTo(openCodeDir, 'skills', skills)
82
82
 
83
83
  removedCount += await removeUnselected(