create-dwy 0.23.0 → 0.25.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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # create-dwy
2
2
 
3
+ ## [0.25.0] - 2026-11-06
4
+
5
+ ### Minor Changes
6
+
7
+ - 同步流程简化为「范围 → Skills → 项目 Rules → 通用规范 → Hooks → CLI → 预览确认」。规范和 Hooks 默认启用;Codex 默认选中,并恢复当前范围已同步的其他 CLI。项目已有有效全局规范或完整 Hooks 时跳过对应选择,不重复安装。
8
+ - 模板从公开源码仓库 master 分支运行时获取,新增 Skills 无需再发布 CLI。使用增量 Git 对象缓存和验证后的模板快照;离线保留有效缓存,无缓存才回退随包模板。首次下载显示进度,异常退出后的锁可自动恢复。
9
+ - 确认后刷新当前范围所需的共享 Skills,项目补其他 CLI 入口时保留有效全局同名共享内容;执行后报告实际完成信息。保留旧脚本的显式同步范围、个人配置及取消前目标不写入的约束。
10
+ - 新增版本克制与想法收录 Skill,支持新增功能分析、阶段收敛、持久化想法清单和新版本规划提醒。
11
+ - 修复无同步记录的旧 Cursor Rules 默认选择恢复,补充全局继承、内容保护、增量更新、离线及多进程锁回归,并更新同步帮助与使用说明。
12
+
13
+ ---
14
+
15
+ ## [0.24.1] - 2026-11-03
16
+
17
+ ### Patch Changes
18
+
19
+ - 包含 0.24.0 的 Skills / Hooks / Rules / 通用规范管理、显式移除和全局去重能力。
20
+ - 收敛配置管理集成测试的无断言日志,规避 Node 22 测试运行器在大量中文 stdout 下的 IPC 反序列化问题;保留全部断言和真实终端输出测试,不改变 CLI 行为。
21
+
22
+ ---
23
+
24
+ ## [0.24.0] - 2026-11-03
25
+
26
+ ### Minor Changes
27
+
28
+ - 全局提供 Skills、Hooks 和 AGENTS.md 通用规范,项目另外提供 Rules。全局首次默认启用通用规范;Hooks 与规范增加显式启用/更新和移除动作,未选择的类型保持原样,规范移除后不自动复选。
29
+ - 项目通用规范与 Rules 分别操作各自托管块,移除任一项保留其他内容。任一所选 CLI 已有有效全局基线时,项目禁止重复安装,共享项目文件的混合平台场景提示补齐缺失的全局入口。
30
+ - Hooks 按有效脚本及对应事件、matcher 和完整命令注册判断全局提供者,项目只补缺失项。全局启用后明确清理当前项目重复脚本和注册项;移除全局配置不自动回填项目。
31
+ - Hook 清理支持历史托管项,保留同名个人路径、组合命令和其他设置;配置目录及单文件链接在规划和执行时检查,防止项目操作穿透改写全局或外部目标。
32
+ - 补充配置独立启停、移除记忆、继承、取消及真实终端回归,旧测试统一注入临时用户目录。
33
+
34
+ ---
35
+
3
36
  ## [0.23.0] - 2026-11-02
4
37
 
5
38
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "项目脚手架与多平台 AI 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli-help.js CHANGED
@@ -14,36 +14,39 @@ export function buildHelpText(version) {
14
14
  `dwy ${version}`,
15
15
  '',
16
16
  '用法:',
17
- ' dwy 选择范围、平台和处理内容,预览清单后同步',
17
+ ' dwy 选择范围、配置和 CLI,预览清单后同步',
18
18
  ' dwy sync 同上',
19
19
  ' dwy upgrade 升级到 npm 最新正式版,并刷新共享 Skills',
20
- ' dwy refresh 用当前 CLI 模板刷新已安装的共享 Skills',
20
+ ' dwy refresh 用最新模板刷新已安装的共享 Skills,无需升级 CLI',
21
21
  ' dwy migrate 一键迁移老项目已有的托管 Skills,原目录先备份',
22
22
  ' dwy --help 显示本说明',
23
23
  ' dwy --version 显示版本',
24
24
  '',
25
25
  '同步步骤:',
26
26
  ' 1. 选择同步范围:当前项目或全局。',
27
- ' 2. 选择该范围支持的平台;全局不提供 Cursor。',
28
- ' 3. 选择本次处理内容,默认只选 Skills;未选择的类型保持原样。',
29
- ' 项目:Skills、项目 Rules、Hooks;全局:Skills、全局通用规范、Hooks。',
30
- ' 内容选项按平台能力过滤。Hooks 选中类型后按平台自动处理。',
31
- ' 4. 逐项选择 Skills / 项目 Rules;全局通用规范显示摘要。',
32
- ' 5. 查看写入、备份和清理清单,确认后执行;取消不写文件。',
27
+ ' 2. 选择 Skills。',
28
+ ' 3. 仅项目范围选择 Rules;全局跳过。',
29
+ ' 4. 选择 AGENTS.md 通用规范,默认启用。',
30
+ ' 5. 选择 Hooks,默认启用。',
31
+ ' 项目已有有效全局规范或全部所需全局 Hooks 时,跳过对应操作页。',
32
+ ' 6. 最后选择 CLI;首次只选 Codex,后续恢复当前范围同步过的 CLI 与有效旧托管入口。',
33
+ ' 7. 按最终 CLI 重新核算覆盖,预览实际写入、迁移、备份和清理清单。',
34
+ ' 8. 确认后执行,显示实际完成信息;取消不改同步目标。',
33
35
  ' Commands 不参与同步。',
34
36
  '',
35
37
  '交互按键:',
36
38
  ' Skills / Rules:←/→ 切分类,↑/↓ 移动,Space 勾选,a 全选/清空当前分类。',
37
- ' 范围 / 平台 / 内容:↑/↓ 移动;多选用 Space 勾选,可输入关键字筛选。',
39
+ ' 范围 / 通用规范 / Hooks / CLI:↑/↓ 移动;CLI 用 Space 勾选,可输入关键字筛选。',
38
40
  ' Enter 确认当前步骤,Esc 或 Ctrl+C 取消。',
39
41
  '',
40
42
  '常见用法:',
41
- ' 同步当前项目:cd your-project 后执行 dwy,选择「当前项目」及本次处理内容。',
42
- ' 同步全局:执行 dwy,选择「全局」及 Skills、全局通用规范或 Hooks。',
43
+ ' 同步当前项目:cd your-project 后执行 dwy,选择「当前项目」。',
44
+ ' 同步全局:执行 dwy,选择「全局」。',
45
+ ' 移除配置:在当前范围取消 Skills / Rules 勾选,或关闭 Hooks / 通用规范,再确认。',
43
46
  ' 更新或调整配置:再次执行 dwy sync,检查勾选项,再确认同步。',
44
47
  ' 更新 CLI:dwy upgrade(全局安装时可用;更新后执行 dwy --version 查看版本)。',
45
48
  ' 迁移老项目:cd your-project 后执行 dwy migrate,无需重新勾选。',
46
- ' 手动升级包后:执行一次 dwy refresh,所有已迁移的链接同时生效。',
49
+ ' 只更新 Skills:执行 dwy refresh,所有已迁移的链接同时生效。',
47
50
  ' 首次安装:npm install -g create-dwy@latest',
48
51
  '',
49
52
  '平台支持:',
@@ -52,18 +55,32 @@ export function buildHelpText(version) {
52
55
  ' OpenCode:全局 ~/.config/opencode/AGENTS.md;项目根 AGENTS.md;当前不支持 Hooks。',
53
56
  ' Cursor:仅项目 Rules;全局规则在 Cursor 设置中管理。',
54
57
  '',
55
- '同步仅处理当前范围、所选平台和内容;未选择的类型及其记录保持原样。',
58
+ '同步按当前范围和最终 CLI 执行;项目 AGENTS.md 由多个 CLI 共享。',
56
59
  '用户主目录不能作为项目范围同步,请选择全局或进入项目目录。',
57
- '取消已有勾选会在确认后移除当前范围的托管入口;不勾选整个类型则不处理。',
60
+ '关闭选项只移除当前范围托管内容;项目操作不移除全局配置。',
58
61
  'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
59
62
  '项目 Skills 全部适用平台均有全局入口时显示「全局已启用」,不能重复勾选。',
60
63
  '部分平台已有全局入口时仍可选,只为缺少全局入口的平台安装项目链接。',
61
- '项目同步只清理项目重复入口,不改全局提供者;真实目录先备份。',
62
- 'sync / migrate 仅准备缺失或损坏副本,不刷新已有共享内容;refresh / upgrade 才刷新。',
64
+ 'Hooks 与 Skills 独立;整组管理,项目仅安装缺少有效全局注册的 Hooks。',
65
+ '全局 Hook 的脚本与对应事件、matcher、完整命令注册均有效时,项目禁止重复安装。',
66
+ '任一适用 CLI 已有 dwy 全局通用规范时,项目继承并清理重复基线;最终 CLI 中缺失的全局规范需补齐。',
67
+ '手写 AGENTS.md 本身不算全局已安装;通用规范与 Rules 共用文件但独立管理。',
68
+ '移除通用规范仅删除基线块;项目 Rules 独立管理,保留手写内容。',
69
+ '全局启用会明确列出当前项目重复项清理;全局移除不会自动向项目补装。',
70
+ '项目同步只清理项目重复入口,不改写有效全局提供者;真实目录先备份。',
71
+ '确认前仅可更新模板缓存;确认后才改项目/全局配置,并刷新所选共享 Skills。',
72
+ '由有效全局入口提供的 Skills 不借项目同步改写;全部已安装副本用 dwy refresh 更新。',
73
+ 'sync / migrate 脚本接口仍须显式指定范围。',
63
74
  '产品流程缺失的外部依赖也列入清单,确认后联网补齐,已有内容保留。',
64
75
  '旧真实目录备份到同步根的 .dwy/skill-backups;个人 Skills、Rules、Hooks 不参与 migrate。',
65
76
  '旧项目首次需执行 dwy migrate;迁移后无需逐项目刷新 Skills。',
66
77
  'Rules / Hooks 的模板更新仍需执行 dwy sync;已有会话缓存的 Skill 可能需要重新加载。',
67
78
  '',
79
+ '模板更新:',
80
+ ' 固定公开仓库 https://github.com/danweiyuancircle/dwy-ai-experience.git 的 master 分支 dwy-cli/templates/ai-tools。',
81
+ ' DWY_TEMPLATE_SOURCE_DIR 开发覆盖优先;远端模板缓存在 ~/.dwy/template-source。',
82
+ ' 未变更只检查 commit;离线保留有效缓存,无有效缓存时回退包内模板并提示。',
83
+ ' 新增 Skills 只需更新模板分发分支,无需发布 CLI;dwy upgrade 更新程序。',
84
+ '',
68
85
  ].join('\n')
69
86
  }
@@ -36,7 +36,7 @@ export async function ensureSharedSkills({ skills, homeDir = os.homedir() }) {
36
36
  }
37
37
 
38
38
  /**
39
- * 将包内 Skills 准备到稳定用户目录,返回指向共享副本的条目。
39
+ * 将当前模板 Skills 准备到稳定用户目录,返回指向共享副本的条目。
40
40
  * 同一用户的刷新互斥;完整副本准备失败时不替换旧内容。外部 Skills 缓存不纳入本模块清单。
41
41
  */
42
42
  export async function prepareSharedSkills({ skills, homeDir = os.homedir() }) {
@@ -183,15 +183,15 @@ export async function linkSkill({ skill, dest, backupRoot }) {
183
183
  }
184
184
  }
185
185
 
186
- /** 使用当前 CLI 包内模板刷新已有共享副本;新包移除的条目保留,避免其他项目坏链。 */
186
+ /** 从最新可用模板刷新已有共享副本;模板移除的条目保留,避免其他项目坏链。 */
187
187
  export async function refreshSharedSkills({ sourceDir, homeDir = os.homedir() } = {}) {
188
188
  const names = await readInstalledNames(homeDir)
189
- const scans = await scanSkills(sourceDir || await resolveSourceDir())
189
+ const scans = await scanSkills(sourceDir || await resolveSourceDir({ homeDir }))
190
190
  const available = new Map(scans.map(skill => [skill.name, skill]))
191
191
  const updated = names.filter(name => available.has(name))
192
192
  const retained = names.filter(name => !available.has(name))
193
193
  await prepareSharedSkills({ skills: updated.map(name => available.get(name)), homeDir })
194
- for (const name of retained) console.log(chalk.yellow(`当前 CLI 已移除 ${name},保留共享旧副本以免其他项目链接失效。`))
194
+ for (const name of retained) console.log(chalk.yellow(`当前模板已移除 ${name},保留共享旧副本以免其他项目链接失效。`))
195
195
  console.log(chalk.green(`已刷新 ${updated.length} 个共享 Skills,保留 ${retained.length} 个旧副本。`))
196
196
  return { updated, retained }
197
197
  }
package/src/sync-all.js CHANGED
@@ -6,12 +6,13 @@ import { SEARCH_PLACEHOLDER, searchableMultiselect, searchableSelect } from './s
6
6
  import { normalizeSkillScope, resolveProjectSkillDir, resolveGlobalSkillDir, copySkillsToProjectPlatforms, deduplicateProjectSkills } from './sync-global-skills.js'
7
7
  import { ensureSharedSkills } from './skill-store.js'
8
8
  import { chalk } from './utils.js'
9
- import { interactiveSelect, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
10
- import { parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
9
+ import { interactiveSelect, loadHookManifests, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
10
+ import { hasManagedBaseline, parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
11
11
  import { scanExistingCursorRules } from './sync-cursor.js'
12
12
  import { installSkills } from './skills-install.js'
13
13
  import { selfUpgrade } from './self-upgrade.js'
14
14
  import { buildSyncPlan, contentOptions, projectSkillChoices, PLATFORM_LABELS, PLATFORM_SYNC_TYPES } from './sync-plan.js'
15
+ import { assertConfigPath, baselineProviders, globalBaselinePath, globalHookNames, hasManagedHookConfig } from './sync-inheritance.js'
15
16
 
16
17
  const PLATFORM_OPTIONS = [
17
18
  { value: 'claude', label: 'Claude Code', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
@@ -58,25 +59,44 @@ export function buildSelectionDefaultsFromSyncState(existing, syncState, platfor
58
59
  return Object.values(result).some(names => names.size) ? result : existing
59
60
  }
60
61
 
61
- /** 从当前范围的实际入口推导平台默认值;不读取另一个范围的配置。 */
62
+ /** Codex 默认选中;恢复当前范围的同步记录和有效旧托管入口,空目录不算已同步。 */
62
63
  export async function buildPlatformDefaultsFromLocalState(projectDir, scope = 'project', codexHome = path.join(projectDir, '.codex')) {
63
- const defaults = []
64
- if (await fs.pathExists(path.join(projectDir, '.claude'))) defaults.push('claude')
65
- if (await fs.pathExists(path.join(projectDir, '.codex'))
66
- || (scope === 'global' && await fs.pathExists(codexHome))
67
- || await fs.pathExists(path.join(projectDir, '.agents'))
68
- || (scope === 'project' && await fs.pathExists(path.join(projectDir, 'AGENTS.md')))) defaults.push('codex')
69
- if (scope === 'project' && await fs.pathExists(path.join(projectDir, '.cursor'))) defaults.push('cursor')
70
- if (await fs.pathExists(path.join(projectDir, ...(scope === 'global' ? ['.config', 'opencode'] : ['.opencode'])))) defaults.push('opencode')
71
- return defaults.length ? defaults : ['claude', 'codex']
64
+ const defaults = ['codex']
65
+ const state = await loadSyncState(projectDir)
66
+ for (const platform of ['claude', 'opencode', ...(scope === 'project' ? ['cursor'] : [])]) {
67
+ const root = scope === 'global' ? resolveGlobalSkillDir(platform, projectDir)
68
+ : resolveProjectSkillDir(projectDir, platform)
69
+ let managed = Boolean(state.platforms[platform])
70
+ if (root && await fs.pathExists(root)) {
71
+ for (const name of await fs.readdir(root)) {
72
+ if (name.startsWith('dwy-') && await fs.pathExists(path.join(root, name, 'SKILL.md'))) managed = true
73
+ }
74
+ }
75
+ if (platform === 'cursor') {
76
+ if ([...await scanExistingCursorRules(projectDir)].some(name => name.startsWith('dwy-') || name === '00-dwy-global.mdc')) managed = true
77
+ } else {
78
+ const baseline = scope === 'global' ? globalBaselinePath(platform, projectDir, codexHome)
79
+ : path.join(projectDir, platform === 'claude' ? '.claude' : '.opencode', 'AGENTS.md')
80
+ if (await hasManagedBaseline(baseline)) managed = true
81
+ if (platform === 'claude') {
82
+ const configRoot = path.join(projectDir, '.claude')
83
+ if (await hasManagedBaseline(path.join(configRoot, 'CLAUDE.md'))
84
+ || await hasManagedHookConfig(platform, configRoot, await scanExisting(configRoot, 'hooks'))) managed = true
85
+ }
86
+ }
87
+ if (managed) defaults.push(platform)
88
+ }
89
+ return defaults
72
90
  }
73
91
 
74
- async function selectPlatforms(root, scope, codexHome) {
92
+ async function selectPlatforms(root, scope, initialValues) {
93
+ const state = await loadSyncState(root)
75
94
  const result = await searchableMultiselect({
76
95
  message: '选择要同步的平台',
77
96
  options: PLATFORM_OPTIONS.filter(option => scope === 'project' || option.value !== 'cursor')
78
- .map(option => ({ ...option, description: contentOptions(scope, [option.value]).map(item => item.label).join('、') })),
79
- initialValues: await buildPlatformDefaultsFromLocalState(root, scope, codexHome),
97
+ .map(option => ({ ...option, description: [state.platforms[option.value] ? '已有同步记录,将核对并修复本地配置' : '',
98
+ contentOptions(scope, [option.value]).map(item => item.label).join('、')].filter(Boolean).join(';') })),
99
+ initialValues,
80
100
  required: true, maxItems: 6, placeholder: SEARCH_PLACEHOLDER,
81
101
  })
82
102
  return isCancel(result) ? null : result
@@ -126,7 +146,7 @@ function staleEntries(scans, state, existing, platforms) {
126
146
  const result = {}
127
147
  for (const platform of platforms) {
128
148
  result[platform] = {}
129
- for (const type of PLATFORM_SYNC_TYPES[platform]) {
149
+ for (const type of PLATFORM_SYNC_TYPES[platform].filter(type => current[type])) {
130
150
  result[platform][type] = (state.platforms[platform]?.[type] || []).filter(name =>
131
151
  !current[type].has(name) && existing[platform][type].has(platform === 'cursor' && type === 'rules' ? buildCursorRuleName(name) : name))
132
152
  }
@@ -139,7 +159,7 @@ function normalizeRemovals(raw) {
139
159
  Object.fromEntries(Object.entries(types).map(([type, names]) => [type, new Set(names)]))]))
140
160
  }
141
161
 
142
- /** `dwy` 和 `dwy sync` 共用范围 → 平台 → 内容 → 逐项选择 → 清单确认。 */
162
+ /** `dwy` 和 `dwy sync` 共用范围 → Skills → 项目 Rules → 规范 → Hooks → CLI → 确认。 */
143
163
  export async function runDwy(opts = {}) {
144
164
  console.log(chalk.bold.cyan('\ndwy') + chalk.gray(' · AI 开发工具\n'))
145
165
  const action = normalizeAction(opts.action)
@@ -165,7 +185,7 @@ export async function migrateSkills({
165
185
  if (await fs.pathExists(homeDir) && realProject === await fs.realpath(homeDir)) {
166
186
  throw new Error('当前目录是用户主目录,请使用全局同步或进入项目目录迁移')
167
187
  }
168
- const scans = await scanSkills(sourceDir || await resolveSourceDir())
188
+ const scans = await scanSkills(sourceDir || await resolveSourceDir({ homeDir }))
169
189
  const available = new Map(scans.map(skill => [skill.name, skill]))
170
190
  const state = await loadSyncState(projectDir)
171
191
  const existingByPlatform = {}
@@ -213,65 +233,107 @@ export async function migrateSkills({
213
233
 
214
234
  /**
215
235
  * 只执行点名范围和内容;测试/脚本可注入选择,确认前只读。
216
- * contentTypes 未传时,已有脚本按 selected 中明确提供的字段处理;主交互默认只选 Skills。
236
+ * 主交互按内容顺序收集选择,CLI 后选,执行前重新核算继承;显式脚本仅处理点名的类型。
217
237
  */
218
238
  export async function syncAll({
219
239
  sourceDir: sourceDirOverride, projectDir: projectDirOverride, selected: selectedOverride,
220
240
  selectedPlatforms: platformsOverride, contentTypes: typesOverride, staleRemovals: removalsOverride,
221
241
  skillScope: skillScopeOverride, syncMode: modeOverride, homeDir = os.homedir(),
222
242
  codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'), scope: scopeOverride, confirmed,
243
+ contentActions: actionsOverride,
223
244
  } = {}) {
224
- const sourceDir = sourceDirOverride || await resolveSourceDir()
245
+ const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
225
246
  if (!await fs.pathExists(sourceDir)) throw new Error('ai-tools templates not found in repo')
226
247
  const projectDir = projectDirOverride || process.cwd()
227
248
  const scope = scopeOverride || (selectedOverride || platformsOverride ? 'project' : await searchableSelect({
228
249
  message: '选择同步范围',
229
250
  options: [
230
- { value: 'project', label: '当前项目', description: '管理项目启用的 Skills、项目 Rules 和 Hooks;全局已有 Skills 自动去重' },
231
- { value: 'global', label: '全局', description: '管理用户级 Skills、通用规范和 Hooks;项目重复 Skill 入口会明确列出清理项' },
251
+ { value: 'project', label: '当前项目', description: '管理 Skills、Hooks、Rules 和通用规范;继承有效全局配置' },
252
+ { value: 'global', label: '全局', description: '管理 Skills、Hooks 和通用规范;清理当前项目重复入口' },
232
253
  ], initialValue: 'project',
233
254
  }))
234
255
  if (isCancel(scope)) { console.log(chalk.yellow('已取消同步。')); return }
235
256
  if (!['project', 'global'].includes(scope)) throw new Error('无效同步范围')
236
257
  const global = scope === 'global', targetRoot = global ? homeDir : projectDir
237
- const platforms = platformsOverride || await selectPlatforms(targetRoot, scope, codexHome)
238
- if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
258
+ let platforms = platformsOverride || await buildPlatformDefaultsFromLocalState(targetRoot, scope, codexHome)
239
259
  if (platforms.length === 0 || platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))) throw new Error('所选平台不支持此同步范围')
240
- const options = contentOptions(scope, platforms)
241
- const allowed = new Set(options.map(option => option.value))
260
+ const projectState = await loadSyncState(projectDir), globalState = await loadSyncState(homeDir)
261
+ const targetState = global ? globalState : projectState
262
+ const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
263
+ const mainFlow = !typesOverride && !selectedOverride && modeOverride !== SYNC_MODE_SKILLS
264
+ const availablePlatforms = PLATFORM_OPTIONS.filter(option => !global || option.value !== 'cursor').map(option => option.value)
265
+ const allowed = new Set(contentOptions(scope, mainFlow ? availablePlatforms : platforms).map(option => option.value))
242
266
  let contentTypes = typesOverride
243
267
  if (!contentTypes && modeOverride === SYNC_MODE_SKILLS) contentTypes = ['skills']
244
268
  if (!contentTypes && selectedOverride) contentTypes = Object.keys(selectedOverride).filter(type => allowed.has(type))
245
269
  if (!contentTypes) {
246
- const result = await searchableMultiselect({
247
- message: '选择本次处理内容(未勾选的类型保持原样)', options,
248
- initialValues: allowed.has('skills') ? ['skills'] : [], required: true, maxItems: 5,
249
- })
250
- if (isCancel(result)) { console.log(chalk.yellow('已取消同步。')); return }
251
- contentTypes = result
270
+ contentTypes = [...allowed]
271
+ if (!await fs.pathExists(path.join(sourceDir, 'AGENTS.md'))) contentTypes = contentTypes.filter(type => type !== 'baseline')
272
+ if (!scans.hooks.length) contentTypes = contentTypes.filter(type => type !== 'hooks')
252
273
  }
253
274
  if (contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
254
275
  if (contentTypes.length === 0) { console.log('未选择处理内容。'); return }
255
- const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
256
- const projectState = await loadSyncState(projectDir), globalState = await loadSyncState(homeDir)
257
- const targetState = global ? globalState : projectState
258
276
  const existing = await managedNames(targetRoot, global, codexHome)
277
+ const cursorRules = new Set(scans.rules.filter(rule => existing.cursor.rules.has(buildCursorRuleName(rule.name))).map(rule => rule.name))
259
278
  const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type,
260
- unionSets(...platforms.map(platform => existing[platform][type]))]))
279
+ unionSets(...platforms.map(platform => platform === 'cursor' && type === 'rules' ? cursorRules : existing[platform][type]))]))
261
280
  const defaults = buildSelectionDefaultsFromSyncState(current, targetState, platforms)
262
281
  for (const type of ['skills', 'rules', 'hooks']) defaults[type] = unionSets(defaults[type], current[type])
263
- const choices = !global && contentTypes.includes('skills')
282
+ // CLI 尚未选择时允许勾选所有 Skills;最终计划按实际 CLI 去重,避免提前禁用其他 CLI 所需条目。
283
+ const choices = platformsOverride && !global && contentTypes.includes('skills')
264
284
  ? { ...scans, skills: await projectSkillChoices(scans.skills, platforms, { homeDir, codexHome }) } : scans
265
285
  const selected = selectedOverride
266
286
  ? { skills: selectedOverride.skills || [], rules: selectedOverride.rules || [], hooks: [], commands: [] }
267
287
  : await interactiveSelect(choices, defaults, { types: contentTypes })
268
288
  if (selected === null) { console.log(chalk.yellow('已取消同步。')); return }
289
+ const contentActions = { ...actionsOverride }
290
+ if (!selectedOverride) {
291
+ for (const type of ['baseline', 'hooks'].filter(type => contentTypes.includes(type))) {
292
+ if (contentActions[type]) continue
293
+ const inherited = type === 'baseline' && !global ? await baselineProviders(platforms, { homeDir, codexHome }) : []
294
+ const name = type === 'hooks' ? 'Hooks' : 'AGENTS.md 通用规范'
295
+ let inheritedHooks = false
296
+ if (type === 'hooks' && !global) {
297
+ const manifests = await loadHookManifests(sourceDir)
298
+ let total = 0, covered = 0
299
+ for (const platform of platforms.filter(p => PLATFORM_SYNC_TYPES[p].includes('hooks'))) {
300
+ const available = scans.hooks.filter(hook => manifests.some(manifest => manifest.name === hook.name && (!manifest.platforms || manifest.platforms.includes(platform))))
301
+ const provided = await globalHookNames({ platform, sourceDir, homeDir, codexHome })
302
+ total += available.length
303
+ covered += available.filter(hook => provided.has(hook.name)).length
304
+ }
305
+ inheritedHooks = total > 0 && covered === total
306
+ }
307
+ if (inherited.length || inheritedHooks) {
308
+ contentActions[type] = 'sync'
309
+ continue
310
+ }
311
+ const action = await searchableSelect({
312
+ message: `选择 ${name} 操作`, initialValue: 'sync',
313
+ options: [
314
+ { value: 'sync', label: '启用 / 更新',
315
+ description: type === 'hooks' && !global ? '只为缺少有效全局注册的 Hook 安装项目配置,其他仅去重' : '执行清单会列出实际路径及清理项' },
316
+ { value: 'remove', label: `移除当前范围的${name}`, description: '只移除 dwy 托管内容,保留个人配置和其他类型' },
317
+ ],
318
+ })
319
+ if (isCancel(action)) { console.log(chalk.yellow('已取消同步。')); return }
320
+ contentActions[type] = action
321
+ }
322
+ }
323
+ if (!platformsOverride) {
324
+ platforms = await selectPlatforms(targetRoot, scope, platforms)
325
+ if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
326
+ }
327
+ const options = contentOptions(scope, platforms)
328
+ if (mainFlow) contentTypes = contentTypes.filter(type => options.some(option => option.value === type))
269
329
  const stale = staleEntries(scans, targetState, existing, platforms)
270
330
  const removals = normalizeRemovals(removalsOverride || stale)
271
331
  const baseline = contentTypes.some(type => type === 'rules' || type === 'baseline') ? await readBaselineDoc(sourceDir) : ''
272
332
  const plan = await buildSyncPlan({
273
333
  scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
274
334
  projectState, globalState, staleRemovals: removals, staleEntries: stale, baseline,
335
+ contentActions,
336
+ refreshSkills: mainFlow,
275
337
  skillScope: normalizeSkillScope(skillScopeOverride),
276
338
  })
277
339
  const nextTarget = global ? plan.nextGlobal : plan.nextProject
@@ -279,16 +341,21 @@ export async function syncAll({
279
341
  nextTarget.syncMode = contentTypes.length === 1 && contentTypes[0] === 'skills' ? SYNC_MODE_SKILLS : SYNC_MODE_ALL
280
342
  nextTarget.skillScope = normalizeSkillScope(skillScopeOverride)
281
343
  const stateFile = path.join(targetRoot, ...SYNC_STATE_PATH)
282
- plan.operations.push({ kind: 'state', label: '更新当前范围同步记录', paths: [stateFile], run: () => saveSyncState(targetRoot, nextTarget) })
344
+ await assertConfigPath(stateFile, targetRoot)
345
+ plan.operations.push({ kind: 'state', label: '更新当前范围同步记录', paths: [stateFile], run: async () => {
346
+ await assertConfigPath(stateFile, targetRoot)
347
+ await saveSyncState(targetRoot, nextTarget)
348
+ } })
283
349
  if (global && plan.projectChanged) plan.operations.push({
284
350
  kind: 'state', label: '更新项目去重记录', paths: [path.join(projectDir, ...SYNC_STATE_PATH)],
285
- run: () => saveSyncState(projectDir, plan.nextProject),
351
+ run: async () => { await assertConfigPath(path.join(projectDir, ...SYNC_STATE_PATH), projectDir); await saveSyncState(projectDir, plan.nextProject) },
286
352
  })
353
+ if (global && plan.projectChanged) await assertConfigPath(path.join(projectDir, ...SYNC_STATE_PATH), projectDir)
287
354
  console.log(`\n同步范围:${global ? '全局' : '当前项目'};平台:${platforms.map(p => PLATFORM_LABELS[p]).join('、')}`)
288
355
  console.log(`处理内容:${contentTypes.map(type => options.find(option => option.value === type).label).join('、')}`)
289
356
  if (contentTypes.includes('skills')) console.log(`Skills:${selected.skills.map(item => item.name).join('、') || '无项目安装选择;已有全局入口仅去重'}`)
290
357
  if (contentTypes.includes('rules')) console.log(`Rules:${selected.rules.map(item => item.name).join('、') || '移除当前项目托管规则'}`)
291
- if (contentTypes.includes('baseline')) console.log(`全局通用规范摘要:${baseline.split('\n').filter(line => /^#/.test(line)).slice(0,3).join(';')}`)
358
+ if (contentTypes.includes('baseline')) console.log(`通用规范:${contentActions.baseline === 'remove' ? '显式移除' : baseline.split('\n').filter(line => /^#/.test(line)).slice(0,3).join(';')}`)
292
359
  for (const operation of plan.operations) {
293
360
  console.log(operation.label)
294
361
  for (const dest of operation.paths) console.log(` → ${dest}`)
@@ -297,7 +364,10 @@ export async function syncAll({
297
364
  const answer = confirmed === false ? false : await confirm({ message: '确认同步?' })
298
365
  if (isCancel(answer) || !answer) { console.log(chalk.yellow('已取消同步。')); return }
299
366
  }
300
- for (const operation of plan.operations) await operation.run()
367
+ for (const operation of plan.operations) {
368
+ await operation.run()
369
+ if (!['state', 'inherit'].includes(operation.kind)) console.log(chalk.green(`已完成:${operation.label}`))
370
+ }
301
371
  console.log(chalk.green('同步完成。'))
302
372
  return plan.operations.map(({ kind, label, paths }) => ({ kind, label, paths }))
303
373
  }
package/src/sync-codex.js CHANGED
@@ -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)
@@ -253,7 +264,8 @@ function globalHookCommand(configDir, name) {
253
264
  return `bash '${script.replace(/'/g, "'\\''")}'`
254
265
  }
255
266
 
256
- function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
267
+ /** 生成 Codex Hook 注册,继承检测和写入共用相同命令格式。 */
268
+ export function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
257
269
  const hooksJson = { hooks: {} }
258
270
  const grouped = new Map()
259
271
 
@@ -284,14 +296,19 @@ function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
284
296
  return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
285
297
  }
286
298
 
287
- function isManagedCodexHookCommand(command, managedHookNames, configDir) {
299
+ /** 只识别完整生成命令及已知历史格式,保留同名个人路径。 */
300
+ export function isManagedCodexHookCommand(command, managedHookNames, configDir) {
288
301
  if (typeof command !== 'string') return false
289
- const match = command.match(/\.codex\/hooks\/([^\s'"]+)/)
290
- return !!(match && managedHookNames.has(match[1]))
291
- || [...managedHookNames].some(name => command === globalHookCommand(configDir, name))
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))
292
308
  }
293
309
 
294
- function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
310
+ /** 只替换指定托管注册,保留其他命令及配置字段。 */
311
+ export function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
295
312
  const merged = {}
296
313
  const sourceEvents = new Set([
297
314
  ...Object.keys(existingHooks || {}),
@@ -313,14 +330,12 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, config
313
330
  return Object.keys(merged).length > 0 ? merged : undefined
314
331
  }
315
332
 
316
- async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global) {
333
+ async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleHookNames) {
317
334
  const dest = path.join(configDir, 'hooks.json')
318
335
  const manifests = await loadHookManifests(sourceDir)
319
336
  const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
320
337
  const managedHookNames = new Set(
321
- manifests
322
- .filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
323
- .map(manifest => manifest.name),
338
+ [...manifests.map(manifest => manifest.name), ...staleHookNames],
324
339
  )
325
340
 
326
341
  let nextJson
@@ -362,7 +377,7 @@ export async function syncCodex({
362
377
  syncTypes = ['skills', 'hooks'],
363
378
  staleRemovals = {},
364
379
  } = {}) {
365
- const sourceDir = sourceDirOverride || await resolveSourceDir()
380
+ const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
366
381
  if (!await fs.pathExists(sourceDir)) {
367
382
  throw new Error('ai-tools templates not found in repo')
368
383
  }
@@ -402,7 +417,7 @@ export async function syncCodex({
402
417
  }
403
418
  if (syncTypes.includes('hooks')) {
404
419
  syncedCount += await copyCodexHooks(syncedSelection.hooks, configDir)
405
- syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global)
420
+ syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleRemovals.hooks || [])
406
421
  }
407
422
 
408
423
  const skillTemplateNames = new Set([
@@ -108,10 +108,11 @@ export async function scanExistingCursorRules(projectDir) {
108
108
  export async function syncCursor({
109
109
  sourceDir: sourceDirOverride,
110
110
  projectDir,
111
+ homeDir,
111
112
  selected,
112
113
  staleRemovals = {},
113
114
  } = {}) {
114
- const sourceDir = sourceDirOverride || await resolveSourceDir()
115
+ const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
115
116
  if (!await fs.pathExists(sourceDir)) {
116
117
  throw new Error('ai-tools templates not found in repo')
117
118
  }