create-dwy 0.19.1 → 0.20.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +1 -1
  3. package/src/cli-help.js +28 -2
  4. package/src/sync-all.js +95 -583
  5. package/src/sync-codex.js +14 -7
  6. package/src/sync-opencode.js +8 -22
  7. package/src/sync.js +60 -149
  8. package/src/tab-multiselect.js +15 -53
  9. package/templates/ai-tools/CHANGELOG.md +195 -0
  10. package/templates/ai-tools/CLAUDE.md +39 -58
  11. package/templates/ai-tools/VERSION +1 -1
  12. package/templates/ai-tools/hooks/Git/pre-git-commit-ai-signature-check.sh +5 -4
  13. package/templates/ai-tools/rules/Android/dwy-android-core.md +147 -13
  14. package/templates/ai-tools/rules/Android/dwy-android-layering.md +142 -0
  15. package/templates/ai-tools/rules/Android/dwy-android-mvp.md +55 -0
  16. package/templates/ai-tools/rules/Android/dwy-android-support-only.md +2 -0
  17. package/templates/ai-tools/rules/Docker/dwy-docker.md +23 -1
  18. package/templates/ai-tools/rules/Flutter/dwy-flutter-core.md +2 -0
  19. package/templates/ai-tools/rules/Flutter/dwy-flutter-testing.md +1 -1
  20. package/templates/ai-tools/rules/HarmonyOS/dwy-arkts-ui-build.md +12 -8
  21. package/templates/ai-tools/rules/Python/dwy-python-backend.md +4 -43
  22. package/templates/ai-tools/rules/Python/dwy-python-layering.md +169 -0
  23. package/templates/ai-tools/rules/Vue/dwy-vue-core.md +11 -49
  24. package/templates/ai-tools/rules/Vue/dwy-vue-layering.md +153 -0
  25. package/templates/ai-tools/rules/iOS/dwy-apple-layering.md +133 -0
  26. package/templates/ai-tools/rules/iOS/dwy-swift-core.md +2 -0
  27. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-code-craft.md +83 -0
  28. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-dependency-freshness.md +27 -66
  29. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-git-commit.md +8 -219
  30. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-oss-commercial-license.md +27 -123
  31. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-prod-branch.md +31 -0
  32. package/templates/ai-tools/rules//346/225/260/346/215/256/345/272/223/dwy-postgres.md +17 -10
  33. package/templates/ai-tools/rules//346/225/260/346/215/256/345/272/223/dwy-redis.md +6 -3
  34. package/templates/ai-tools/skills/Docker/dwy-deploy-first/SKILL.md +14 -14
  35. package/templates/ai-tools/skills/Docker/dwy-deploy-first/references/chapter-01-mirrors.md +2 -2
  36. package/templates/ai-tools/skills/Docker/dwy-docker/SKILL.md +21 -13
  37. package/templates/ai-tools/skills/Docker/dwy-docker/references/ask-templates.md +13 -12
  38. package/templates/ai-tools/skills/Docker/dwy-docker/references/templates.md +3 -1
  39. package/templates/ai-tools/skills/Docker/dwy-docker/references/version-rules.md +10 -6
  40. package/templates/ai-tools/skills/Docker/dwy-docker/scripts/query_dockerhub.py +21 -6
  41. package/templates/ai-tools/skills/Docker/dwy-docker/scripts/scan_images.sh +2 -2
  42. package/templates/ai-tools/skills/Docker/dwy-mirror-source/SKILL.md +44 -30
  43. package/templates/ai-tools/skills/Docker/dwy-mirror-source/references/dockerfile-snippets.md +2 -0
  44. package/templates/ai-tools/skills/Docker/dwy-mirror-source/references/mirror-providers.md +8 -8
  45. package/templates/ai-tools/skills/Docker/dwy-mirror-source/scripts/apply_mirrors.py +14 -1
  46. package/templates/ai-tools/skills/Docker/dwy-mirror-source/scripts/check_mirrors.py +4 -0
  47. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/SKILL.md +8 -6
  48. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/digest/python-sdk.md +3 -3
  49. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/digest/stream-engine.md +2 -2
  50. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/review-rules/anti-patterns.md +3 -1
  51. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/review-rules/change-checklist.md +4 -4
  52. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/review-rules/lethal-violations.md +5 -5
  53. package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/review-rules/perf-baselines.md +5 -5
  54. package/templates/ai-tools/skills/Python/dwy-eapi/SKILL.md +2 -2
  55. package/templates/ai-tools/skills/Python/dwy-eapi/v0/breaking.md +3 -0
  56. package/templates/ai-tools/skills/Python/dwy-eapi/v0/email-canonical.md +28 -3
  57. package/templates/ai-tools/skills/Python/dwy-eapi/v0/index.md +2 -2
  58. package/templates/ai-tools/skills/Python/dwy-eapi/v0/providers-email.md +12 -2
  59. package/templates/ai-tools/skills/Vue/dwy-eui/references/eui-design-guide.md +1 -1
  60. package/templates/ai-tools/skills//344/272/247/345/223/2010/345/210/2601/dwy-architecture/SKILL.md +11 -14
  61. package/templates/ai-tools/skills//344/272/247/345/223/2010/345/210/2601/dwy-product-launcher/references/state-and-contract.md +1 -1
  62. package/templates/ai-tools/skills//344/272/247/345/223/2010/345/210/2601/dwy-prototype/SKILL.md +1 -1
  63. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-github-action-publish/SKILL.md +32 -15
  64. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-github-action-publish/references/electron-release-workflow-template.yml +3 -1
  65. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-github-action-publish/references/release-workflow-template.yml +8 -5
  66. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/SKILL.md +1 -1
  67. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/assets/appstore-submission.yaml +1 -1
  68. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/references/aso.md +2 -0
  69. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/references/in-app-purchase-review.md +1 -1
  70. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +4 -3
  71. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +1 -1
  72. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/backend-deploy.md +1 -1
  73. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +45 -27
  74. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/frontend-deploy.md +1 -1
  75. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/generic-release.md +1 -1
  76. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/git-tag.md +1 -1
  77. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +1 -1
  78. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +1 -1
  79. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +8 -0
  80. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/SKILL.md +7 -7
  81. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/references/checks-runtime.md +63 -59
  82. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/scripts/check_base.sh +10 -1
  83. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/scripts/check_capacity.sh +21 -23
  84. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/scripts/check_db.sh +9 -1
  85. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/scripts/check_docker.sh +20 -24
  86. package/templates/ai-tools/skills//345/256/211/345/205/250/dwy-deploy-audit/scripts/check_logs.sh +3 -3
  87. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/SKILL.md +4 -2
  88. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/references/api.md +1 -1
  89. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/scripts/synthesize.py +1 -0
  90. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/media-platform-packaging/SKILL.md +7 -5
  91. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/media-platform-packaging/references/platform-copy-examples.md +5 -3
  92. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-dependency-choice/SKILL.md +140 -0
  93. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-git-commit/SKILL.md +113 -0
  94. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-shared/SKILL.md +16 -13
  95. package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-whiteboard-prototype/SKILL.md +1 -1
  96. package/src/sync-packs.js +0 -223
  97. package/src/sync-scene.js +0 -178
  98. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-tdd-development.md +0 -115
package/src/sync-codex.js CHANGED
@@ -114,8 +114,9 @@ function upsertBaselineBlock(content, baselineBody) {
114
114
 
115
115
  export async function syncProjectAgentsMd(sourceDir, projectDir, selectedRules, {
116
116
  preservedRuleNames = new Set(),
117
+ targetPath = path.join(projectDir, 'AGENTS.md'),
117
118
  } = {}) {
118
- const dest = path.join(projectDir, 'AGENTS.md')
119
+ const dest = targetPath
119
120
  const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
120
121
  const baselineBody = await readBaselineDoc(sourceDir)
121
122
  const selectedRuleNames = new Set(selectedRules.map(rule => rule.name))
@@ -132,6 +133,7 @@ export async function syncProjectAgentsMd(sourceDir, projectDir, selectedRules,
132
133
  const withBaseline = upsertBaselineBlock(current, baselineBody)
133
134
  const next = upsertManagedBlock(withBaseline, blockBody)
134
135
  if (next === current) return 0
136
+ await fs.ensureDir(path.dirname(dest))
135
137
  await fs.writeFile(dest, next)
136
138
  const parts = []
137
139
  if (baselineBody.trim()) parts.push('baseline')
@@ -223,7 +225,7 @@ async function copyCodexHooks(hooks, proj) {
223
225
  return hooks.length
224
226
  }
225
227
 
226
- function buildCodexHooksJson(manifests, selectedHookNames) {
228
+ function buildCodexHooksJson(manifests, selectedHookNames, global) {
227
229
  const hooksJson = { hooks: {} }
228
230
  const grouped = new Map()
229
231
 
@@ -237,7 +239,9 @@ function buildCodexHooksJson(manifests, selectedHookNames) {
237
239
  const hooks = grouped.get(key) || []
238
240
  hooks.push({
239
241
  type: 'command',
240
- command: `bash \"$(git rev-parse --show-toplevel)\"/.codex/hooks/${manifest.name}`,
242
+ command: global
243
+ ? `bash "$HOME/.codex/hooks/${manifest.name}"`
244
+ : `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
241
245
  ...(manifest.timeout ? { timeout: manifest.timeout } : {}),
242
246
  })
243
247
  grouped.set(key, hooks)
@@ -280,10 +284,10 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
280
284
  return Object.keys(merged).length > 0 ? merged : undefined
281
285
  }
282
286
 
283
- async function syncCodexHooksJson(sourceDir, proj, selectedHookNames) {
287
+ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
284
288
  const dest = path.join(proj, '.codex', 'hooks.json')
285
289
  const manifests = await loadHookManifests(sourceDir)
286
- const generated = buildCodexHooksJson(manifests, selectedHookNames)
290
+ const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
287
291
  const managedHookNames = new Set(
288
292
  manifests
289
293
  .filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
@@ -317,9 +321,11 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames) {
317
321
  return 1
318
322
  }
319
323
 
324
+ /** 同步 Codex 配置;global 模式将规则写入用户 .codex/AGENTS.md。 */
320
325
  export async function syncCodex({
321
326
  sourceDir: sourceDirOverride,
322
327
  projectDir,
328
+ global = false,
323
329
  selected,
324
330
  staleRemovals = {},
325
331
  preserveMissingRules = new Set(),
@@ -340,7 +346,7 @@ export async function syncCodex({
340
346
  }
341
347
  console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.hooks.length} hooks\n`))
342
348
 
343
- const agentsMdPath = path.join(proj, 'AGENTS.md')
349
+ const agentsMdPath = global ? path.join(proj, '.codex', 'AGENTS.md') : path.join(proj, 'AGENTS.md')
344
350
  const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
345
351
  const existing = {
346
352
  skills: await scanExistingCodexSkills(proj),
@@ -363,9 +369,10 @@ export async function syncCodex({
363
369
  syncedCount += await copySkillsFlat(syncedSelection.skills, proj)
364
370
  syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules, {
365
371
  preservedRuleNames: preserveMissingRules,
372
+ targetPath: agentsMdPath,
366
373
  })
367
374
  syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
368
- syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames)
375
+ syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames, global)
369
376
 
370
377
  const skillTemplateNames = new Set([
371
378
  ...scans.skills.map(s => s.name),
@@ -5,7 +5,6 @@ import {
5
5
  copyFilter,
6
6
  logAction,
7
7
  resolveSourceDir,
8
- scanCommands,
9
8
  scanExisting,
10
9
  scanRules,
11
10
  scanSkills,
@@ -27,8 +26,8 @@ async function copyItemsTo(baseDir, typeDir, items) {
27
26
  return count
28
27
  }
29
28
 
30
- async function removeUnselected(typeDir, existingNames, selectedNames, templateNames, projectDir) {
31
- const targetDir = path.join(projectDir, '.opencode', typeDir)
29
+ async function removeUnselected(typeDir, existingNames, selectedNames, templateNames, baseDir) {
30
+ const targetDir = path.join(baseDir, typeDir)
32
31
  let count = 0
33
32
 
34
33
  for (const name of existingNames) {
@@ -42,9 +41,11 @@ async function removeUnselected(typeDir, existingNames, selectedNames, templateN
42
41
  return count
43
42
  }
44
43
 
44
+ /** 同步 OpenCode 规则和技能;global 模式使用用户 .config/opencode 目录。 */
45
45
  export async function syncOpenCode({
46
46
  sourceDir: sourceDirOverride,
47
47
  projectDir,
48
+ global = false,
48
49
  selected,
49
50
  staleRemovals = {},
50
51
  preserveMissingRules = new Set(),
@@ -55,7 +56,7 @@ export async function syncOpenCode({
55
56
  }
56
57
 
57
58
  const proj = projectDir || process.cwd()
58
- const openCodeDir = path.join(proj, '.opencode')
59
+ const openCodeDir = global ? path.join(proj, '.config', 'opencode') : path.join(proj, '.opencode')
59
60
  await fs.ensureDir(openCodeDir)
60
61
 
61
62
  console.log(chalk.blue('\nSyncing OpenCode configuration...\n'))
@@ -63,31 +64,28 @@ export async function syncOpenCode({
63
64
  const scans = {
64
65
  skills: await scanSkills(sourceDir),
65
66
  rules: await scanRules(sourceDir),
66
- commands: await scanCommands(sourceDir),
67
67
  }
68
68
 
69
69
  const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
70
70
  const syncedSelection = {
71
71
  skills: scans.skills.filter(item => selectedNames('skills').has(item.name)),
72
72
  rules: scans.rules.filter(item => selectedNames('rules').has(item.name)),
73
- commands: scans.commands.filter(item => selectedNames('commands').has(item.name)),
74
73
  }
75
74
 
76
75
  const existing = {
77
76
  skills: await scanExisting(openCodeDir, 'skills'),
78
- commands: await scanExisting(openCodeDir, 'commands'),
79
77
  }
80
78
 
81
79
  let syncedCount = 0
82
80
  let removedCount = 0
83
81
 
84
- const agentsPath = path.join(proj, 'AGENTS.md')
82
+ const agentsPath = path.join(global ? openCodeDir : proj, 'AGENTS.md')
85
83
  syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules, {
86
84
  preservedRuleNames: preserveMissingRules,
85
+ targetPath: agentsPath,
87
86
  })
88
87
 
89
88
  syncedCount += await copyItemsTo(openCodeDir, 'skills', syncedSelection.skills)
90
- syncedCount += await copyItemsTo(openCodeDir, 'commands', syncedSelection.commands)
91
89
 
92
90
  removedCount += await removeUnselected(
93
91
  'skills',
@@ -97,22 +95,10 @@ export async function syncOpenCode({
97
95
  ...scans.skills.map(item => item.name),
98
96
  ...(staleRemovals.skills || new Set()),
99
97
  ]),
100
- proj,
101
- )
102
- removedCount += await removeUnselected(
103
- 'commands',
104
- existing.commands,
105
- new Set(syncedSelection.commands.map(item => item.name)),
106
- new Set([
107
- ...scans.commands.map(item => item.name),
108
- ...(staleRemovals.commands || new Set()),
109
- ]),
110
- proj,
98
+ openCodeDir,
111
99
  )
112
-
113
100
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
114
101
  console.log(chalk.gray(` Rules → ${agentsPath}`))
115
102
  console.log(chalk.gray(` Skills → ${path.join(openCodeDir, 'skills')}`))
116
- console.log(chalk.gray(` Commands → ${path.join(openCodeDir, 'commands')}`))
117
103
  console.log(chalk.gray(' Hooks → 当前适配未支持'))
118
104
  }
package/src/sync.js CHANGED
@@ -1,18 +1,12 @@
1
1
  import fs from 'fs-extra'
2
2
  import path from 'path'
3
3
  import { isCancel } from '@clack/prompts'
4
- import {
5
- SEARCH_PLACEHOLDER,
6
- searchableMultiselect,
7
- searchableSelect,
8
- } from './searchable-select.js'
4
+ import { tabMultiselect } from './tab-multiselect.js'
9
5
  import { chalk, DEFAULT_TEMPLATE_REPO_URL, getDwyHomeDir, runGit } from './utils.js'
10
6
 
11
7
  const CATEGORIES = [
12
8
  { key: 'skills', label: 'Skills' },
13
9
  { key: 'rules', label: 'Rules' },
14
- { key: 'commands', label: 'Commands' },
15
- { key: 'hooks', label: 'Hooks' },
16
10
  ]
17
11
 
18
12
  const COPY_EXCLUDE_PATTERNS = [
@@ -147,27 +141,6 @@ export async function scanRules(sourceDir) {
147
141
  return rules.sort((a, b) => a.name.localeCompare(b.name))
148
142
  }
149
143
 
150
- export async function scanCommands(sourceDir) {
151
- const commandsDir = path.join(sourceDir, 'commands')
152
- if (!await fs.pathExists(commandsDir)) return []
153
-
154
- const entries = await fs.readdir(commandsDir, { withFileTypes: true })
155
- const commands = await Promise.all(entries
156
- .filter(e => e.name !== '.gitkeep' && e.name !== '.DS_Store')
157
- .map(async e => ({
158
- name: e.name,
159
- description: await readEntryDescription(
160
- path.join(commandsDir, e.name),
161
- e.isDirectory() ? '命令目录' : '命令文件',
162
- ),
163
- category: e.isDirectory() ? '目录命令' : '文件命令',
164
- sourcePath: path.join(commandsDir, e.name),
165
- type: 'command',
166
- })))
167
-
168
- return commands.sort((a, b) => a.name.localeCompare(b.name))
169
- }
170
-
171
144
  export async function scanHooks(sourceDir) {
172
145
  const hooksDir = path.join(sourceDir, 'hooks')
173
146
  if (!await fs.pathExists(hooksDir)) return []
@@ -214,123 +187,58 @@ export async function scanExisting(projectTargetDir, typePlural) {
214
187
  }
215
188
 
216
189
  /**
217
- * 将扫描项转为可搜索扁平 options。
218
- * label 带分类前缀便于筛选;完整 description 进底部公共说明区(不截断塞 hint)。
190
+ * 按模板实际分类生成 Tab,每条独立勾选,不做包展开或跨分类关联。
219
191
  *
220
192
  * @param {Array<{ name: string, category?: string, description?: string }>} items
221
- * @returns {Array<{ value: string, label: string, description?: string }>}
193
+ * @param {'skills' | 'rules'} type
222
194
  */
223
- function buildSearchableOptions(items) {
224
- return items
225
- .slice()
226
- .sort((a, b) => {
227
- const catCmp = (a.category || '其他').localeCompare(b.category || '其他', 'zh')
228
- if (catCmp !== 0) return catCmp
229
- return a.name.localeCompare(b.name, 'zh')
230
- })
231
- .map(item => {
232
- const category = item.category || '其他'
233
- return {
234
- value: item.name,
235
- label: `${category} / ${item.name}`,
236
- // 完整描述给底部公共区;搜索也匹配 description
237
- description: item.description || undefined,
238
- }
195
+ export function buildCategoryTabs(items, type) {
196
+ const groups = new Map()
197
+ for (const item of items) {
198
+ const category = item.category || '其他'
199
+ if (!groups.has(category)) groups.set(category, [])
200
+ groups.get(category).push({
201
+ value: item.name,
202
+ label: item.name,
203
+ description: item.description,
204
+ type,
239
205
  })
206
+ }
207
+ return [...groups.entries()]
208
+ .sort(([a], [b]) => a.localeCompare(b, 'zh'))
209
+ .map(([category, options]) => ({
210
+ id: category,
211
+ label: category,
212
+ options: options.sort((a, b) => a.label.localeCompare(b.label, 'zh')),
213
+ }))
240
214
  }
241
215
 
242
216
  /**
243
- * 可搜索多选一层。items 为空直接返回 [],不弹窗。
244
- * 取消返回 null,调用方必须中止整次同步。
245
- *
246
- * @param {Array<{ name: string, category?: string, description?: string }>} items
247
- * @param {string} label
248
- * @param {Iterable<string>} defaultNames
249
- */
250
- export async function promptSelection(items, label, defaultNames) {
251
- if (items.length === 0) return []
252
- // 可搜索多选 + 底部说明区(聚焦项描述不跟在行尾)
253
- const selectedNames = await searchableMultiselect({
254
- message: `选择 ${label}`,
255
- options: buildSearchableOptions(items),
256
- initialValues: [...defaultNames],
257
- maxItems: 15,
258
- required: false,
259
- placeholder: SEARCH_PLACEHOLDER,
260
- })
261
- if (isCancel(selectedNames)) return null
262
- const selectedSet = new Set(selectedNames)
263
- return items.filter(item => selectedSet.has(item.name))
264
- }
265
-
266
- /**
267
- * 交互式选择:每步可回退到上一步,最后总览支持任意类别重选。
268
- * categories 可缩成仅 Skills,未出现的类别返回空数组(调用方不得据此删除已有项)。
269
- * 返回 { skills, rules, commands, hooks } 或 null(取消)。
217
+ * 顺序选择 Skills、Rules;Enter 进入下一类,不再提供回退或重选菜单。
218
+ * 完整同步默认启用全部模板 Hooks;仅 Skills 模式不得写入其他配置。
219
+ * 取消返回 null,调用方必须终止同步。
270
220
  *
271
221
  * @param {object} scans
272
- * @param {object} existing
273
- * @param {{ categories?: Array<{ key: string, label: string }> }} [opts]
222
+ * @param {{ skills: Iterable<string>, rules: Iterable<string> }} existing
223
+ * @param {{ skillsOnly?: boolean }} [opts]
274
224
  */
275
- export async function interactiveSelect(scans, existing, { categories = CATEGORIES } = {}) {
276
- const sel = {
277
- skills: [],
278
- rules: [],
279
- commands: [],
280
- hooks: [],
281
- }
282
-
283
- // 第一轮:顺序遍历,每步给导航
284
- let i = 0
285
- while (i < categories.length) {
286
- const { key, label } = categories[i]
287
- const defaultNames = sel[key]?.length
288
- ? new Set(sel[key].map(x => x.name))
289
- : existing[key]
290
- sel[key] = await promptSelection(scans[key], label, defaultNames)
291
- if (sel[key] === null) return null
292
-
293
- if (i === categories.length - 1) break
294
-
295
- const nextLabel = categories[i + 1].label
296
- const prevLabel = i > 0 ? categories[i - 1].label : null
297
- // 导航步可搜索单选(选项少,交互与多选一致)
298
- const nav = await searchableSelect({
299
- message: `${label} 已选 ${sel[key].length} 项。下一步:`,
300
- options: [
301
- { label: `继续选 ${nextLabel}`, value: 'next' },
302
- ...(prevLabel ? [{ label: `返回重选 ${prevLabel}`, value: 'back' }] : []),
303
- { label: '取消同步', value: 'cancel' },
304
- ],
305
- initialValue: 'next',
306
- placeholder: SEARCH_PLACEHOLDER,
307
- })
308
- if (isCancel(nav) || nav === 'cancel') return null
309
- i = nav === 'back' ? i - 1 : i + 1
310
- }
311
-
312
- // 第二轮:总览 + 任意重选
313
- while (true) {
314
- console.log(chalk.gray('\n选择汇总:'))
315
- for (const { key, label } of categories) {
316
- console.log(chalk.gray(` ${label.padEnd(10)} ${sel[key].length} 项`))
317
- }
318
- const final = await searchableSelect({
319
- message: '确认提交还是重选?',
320
- options: [
321
- { label: '确认提交', value: 'confirm' },
322
- ...categories.map(c => ({ label: `重选 ${c.label}`, value: c.key })),
323
- { label: '取消同步', value: 'cancel' },
324
- ],
325
- initialValue: 'confirm',
326
- placeholder: SEARCH_PLACEHOLDER,
225
+ export async function interactiveSelect(scans, existing, { skillsOnly = false } = {}) {
226
+ const selected = { skills: [], rules: [], commands: [], hooks: skillsOnly ? [] : scans.hooks }
227
+ for (const { key, label } of skillsOnly ? CATEGORIES.slice(0, 1) : CATEGORIES) {
228
+ const tabs = buildCategoryTabs(scans[key], key)
229
+ const availableNames = new Set(scans[key].map(item => item.name))
230
+ const names = await tabMultiselect({
231
+ message: `选择 ${label}`,
232
+ tabs,
233
+ initialValues: [...existing[key]].filter(name => availableNames.has(name)),
234
+ required: false,
235
+ maxItems: 15,
327
236
  })
328
- if (isCancel(final) || final === 'cancel') return null
329
- if (final === 'confirm') return sel
330
- const cat = categories.find(c => c.key === final)
331
- sel[final] = await promptSelection(scans[final], cat.label, new Set(sel[final].map(x => x.name)))
332
- if (sel[final] === null) return null
237
+ if (isCancel(names)) return null
238
+ const selectedNames = new Set(names)
239
+ selected[key] = scans[key].filter(item => selectedNames.has(item.name))
333
240
  }
241
+ return selected
334
242
  }
335
243
 
336
244
  export function logAction(label, color = 'green', prefix = '✓') {
@@ -431,7 +339,7 @@ async function removeUnselected(typePlural, existingNames, selectedNames, templa
431
339
  return count
432
340
  }
433
341
 
434
- function buildClaudeHooksSettings(manifests, selectedHookNames) {
342
+ function buildClaudeHooksSettings(manifests, selectedHookNames, global) {
435
343
  const grouped = new Map()
436
344
 
437
345
  for (const manifest of manifests) {
@@ -444,7 +352,7 @@ function buildClaudeHooksSettings(manifests, selectedHookNames) {
444
352
  const hooks = grouped.get(key) || []
445
353
  hooks.push({
446
354
  type: 'command',
447
- command: `bash $CLAUDE_PROJECT_DIR/.claude/hooks/${manifest.name}`,
355
+ command: `bash "${global ? '$HOME' : '$CLAUDE_PROJECT_DIR'}/.claude/hooks/${manifest.name}"`,
448
356
  })
449
357
  grouped.set(key, hooks)
450
358
  }
@@ -489,10 +397,10 @@ function mergeClaudeHooks(existingHooks, generatedHooks, managedHookNames) {
489
397
  return Object.keys(merged).length > 0 ? merged : undefined
490
398
  }
491
399
 
492
- async function syncSettings(sourceDir, targetDir, selectedHookNames) {
400
+ async function syncSettings(sourceDir, targetDir, selectedHookNames, global) {
493
401
  const settingsDest = path.join(targetDir, 'settings.json')
494
402
  const manifests = await loadHookManifests(sourceDir)
495
- const generatedSettings = buildClaudeHooksSettings(manifests, selectedHookNames)
403
+ const generatedSettings = buildClaudeHooksSettings(manifests, selectedHookNames, global)
496
404
  const managedHookNames = new Set(
497
405
  manifests
498
406
  .filter(manifest => !manifest.platforms || manifest.platforms.includes('claude'))
@@ -527,7 +435,7 @@ async function syncSettings(sourceDir, targetDir, selectedHookNames) {
527
435
  return 1
528
436
  }
529
437
 
530
- async function syncClaudeBaselineDoc(sourceDir, targetDir) {
438
+ async function syncClaudeBaselineDoc(sourceDir, targetDir, global) {
531
439
  const baseline = await readBaselineDoc(sourceDir)
532
440
  if (!baseline) return 0
533
441
 
@@ -535,7 +443,15 @@ async function syncClaudeBaselineDoc(sourceDir, targetDir) {
535
443
  const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
536
444
  if (current === baseline) return 0
537
445
 
538
- await fs.writeFile(dest, baseline)
446
+ const start = '<!-- DWY-BASELINE:START -->'
447
+ const end = '<!-- DWY-BASELINE:END -->'
448
+ const block = `${start}\n\n${baseline.trim()}\n\n${end}`
449
+ const next = global
450
+ ? (current === baseline ? `${block}\n` : /<!-- DWY-BASELINE:START[^>]*-->[\s\S]*?<!-- DWY-BASELINE:END -->/.test(current)
451
+ ? current.replace(/<!-- DWY-BASELINE:START[^>]*-->[\s\S]*?<!-- DWY-BASELINE:END -->/, block)
452
+ : `${block}\n\n${current}`)
453
+ : baseline
454
+ await fs.writeFile(dest, next)
539
455
  logAction('.claude/CLAUDE.md')
540
456
  return 1
541
457
  }
@@ -553,9 +469,11 @@ export async function resolveSourceDir() {
553
469
  return resolveTemplateDirFromRepo(repoDir)
554
470
  }
555
471
 
472
+ /** 同步 Claude 配置;global 模式用传入的用户目录,并保留个人 CLAUDE.md 内容。 */
556
473
  export async function syncClaude({
557
474
  sourceDir: sourceDirOverride,
558
475
  projectDir,
476
+ global = false,
559
477
  selected,
560
478
  staleRemovals = {},
561
479
  } = {}) {
@@ -571,7 +489,6 @@ export async function syncClaude({
571
489
  const scans = {
572
490
  skills: await scanSkills(sourceDir),
573
491
  rules: await scanRules(sourceDir),
574
- commands: await scanCommands(sourceDir),
575
492
  hooks: await scanHooks(sourceDir),
576
493
  }
577
494
  const availableHooks = []
@@ -588,12 +505,11 @@ export async function syncClaude({
588
505
  logAction(`hooks skipped: ${missing}`, 'yellow', '!')
589
506
  }
590
507
  scans.hooks = availableHooks
591
- console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.commands.length} commands, ${scans.hooks.length} hooks\n`))
508
+ console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.hooks.length} hooks\n`))
592
509
 
593
510
  const existing = {
594
511
  skills: await scanExisting(projectTargetDir, 'skills'),
595
512
  rules: await scanExisting(projectTargetDir, 'rules'),
596
- commands: await scanExisting(projectTargetDir, 'commands'),
597
513
  hooks: await scanExisting(projectTargetDir, 'hooks'),
598
514
  }
599
515
 
@@ -601,14 +517,12 @@ export async function syncClaude({
601
517
  const syncedSelection = {
602
518
  skills: scans.skills.filter(item => selectedNames('skills').has(item.name)),
603
519
  rules: scans.rules.filter(item => selectedNames('rules').has(item.name)),
604
- commands: scans.commands.filter(item => selectedNames('commands').has(item.name)),
605
520
  hooks: scans.hooks.filter(item => selectedNames('hooks').has(item.name)),
606
521
  }
607
522
 
608
523
  const selectedHookNames = new Set(syncedSelection.hooks.map(h => h.name))
609
524
  const approvedStaleSkills = staleRemovals.skills || new Set()
610
525
  const approvedStaleRules = staleRemovals.rules || new Set()
611
- const approvedStaleCommands = staleRemovals.commands || new Set()
612
526
  const approvedStaleHooks = staleRemovals.hooks || new Set()
613
527
 
614
528
  console.log(chalk.blue(`\nSyncing to ${projectTargetDir}...\n`))
@@ -617,20 +531,17 @@ export async function syncClaude({
617
531
  let syncedCount = 0
618
532
  let removedCount = 0
619
533
 
620
- syncedCount += await syncClaudeBaselineDoc(sourceDir, projectTargetDir)
534
+ syncedCount += await syncClaudeBaselineDoc(sourceDir, projectTargetDir, global)
621
535
  syncedCount += await copyItems(syncedSelection.skills, projectTargetDir)
622
536
  syncedCount += await copyItems(syncedSelection.rules, projectTargetDir)
623
- syncedCount += await copyItems(syncedSelection.commands, projectTargetDir)
624
- syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames)
537
+ syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, global)
625
538
  syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
626
539
 
627
540
  const skillTemplateNames = new Set([...scans.skills.map(s => s.name), ...approvedStaleSkills])
628
541
  const ruleTemplateNames = new Set([...scans.rules.map(r => r.name), ...approvedStaleRules])
629
- const commandTemplateNames = new Set([...scans.commands.map(c => c.name), ...approvedStaleCommands])
630
542
  const hookTemplateNames = new Set([...scans.hooks.map(h => h.name), ...approvedStaleHooks])
631
543
  removedCount += await removeUnselected('skills', existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, projectTargetDir)
632
544
  removedCount += await removeUnselected('rules', existing.rules, new Set(syncedSelection.rules.map(r => r.name)), ruleTemplateNames, projectTargetDir)
633
- removedCount += await removeUnselected('commands', existing.commands, new Set(syncedSelection.commands.map(c => c.name)), commandTemplateNames, projectTargetDir)
634
545
  removedCount += await removeUnselected('hooks', existing.hooks, selectedHookNames, hookTemplateNames, projectTargetDir)
635
546
 
636
547
  console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
@@ -4,7 +4,7 @@
4
4
  * 基于 @clack/core Prompt 自绘,不引入新 UI 库。
5
5
  */
6
6
 
7
- import { Prompt, getColumns, settings } from '@clack/core'
7
+ import { Prompt, getColumns, settings, wrapTextWithPrefix } from '@clack/core'
8
8
  import { limitOptions } from '@clack/prompts'
9
9
  import { styleText } from 'node:util'
10
10
  import process from 'node:process'
@@ -39,39 +39,8 @@ const S_BAR_H = () => unicodeOr('─', '-')
39
39
  const S_TAB_MORE = unicodeOr('‹', '<')
40
40
  const S_TAB_MORE_RIGHT = unicodeOr('›', '>')
41
41
 
42
- /**
43
- * skill / rule / hook 的终端标记。
44
- * 不用 emoji:几何符号 + 固定宽度英文,无 unicode 时退回 S/R/H。
45
- */
46
- const ITEM_TYPE_META = {
47
- skills: { icon: unicodeOr('◈', 'S'), word: 'skill', color: 'cyan', title: '技能' },
48
- rules: { icon: unicodeOr('☰', 'R'), word: 'rule', color: 'yellow', title: '规则' },
49
- hooks: { icon: unicodeOr('⤷', 'H'), word: 'hook', color: 'magenta', title: '钩子' },
50
- }
51
-
52
- /**
53
- * 取类型展示元数据。未知类型当 skill,避免列表画出空白标记。
54
- *
55
- * @param {string | undefined} type
56
- */
57
- export function itemTypeMeta(type) {
58
- return ITEM_TYPE_META[type] || ITEM_TYPE_META.skills
59
- }
60
-
61
- /**
62
- * 行首类型前缀:图标 + 对齐后的 skill/rule/hook。
63
- *
64
- * @param {string | undefined} type
65
- */
66
- export function formatItemTypePrefix(type) {
67
- const meta = itemTypeMeta(type)
68
- return `${styleText(meta.color, meta.icon)} ${styleText(meta.color, meta.word.padEnd(5, ' '))}`
69
- }
70
-
71
42
  /** Tab 之间空格数。过密会糊成一行,过疏又浪费宽度。 */
72
43
  const TAB_GAP = 2
73
- /** 省略角标占位。‹/› 按 1 列计。 */
74
- const ELLIPSIS_W = 1
75
44
  /**
76
45
  * 技术栈 / 场景分组之间插入 ` | ` 比普通间隙多出来的宽度。
77
46
  * 普通间隙 TAB_GAP=2;再加 `|` 和一侧间隙 → 3。
@@ -151,35 +120,31 @@ export function tabWindow(segments, activeIndex, maxWidth, groups) {
151
120
  const n = segments.length
152
121
  if (n === 0) return { start: 0, end: 0, showLeft: false, showRight: false }
153
122
  const idx = Math.min(Math.max(activeIndex, 0), n - 1)
154
- const widths = segments.map(segment => segment.length)
155
-
156
- const used = (start, end) => {
157
- let w = 0
158
- for (let i = start; i < end; i++) {
159
- if (i > start) w += TAB_GAP
160
- w += widths[i]
161
- }
162
- w += groupSepWidth(groups, start, end)
163
- if (start > 0) w += ELLIPSIS_W + TAB_GAP
164
- if (end < n) w += TAB_GAP + ELLIPSIS_W
165
- return w
123
+ const fits = (start, end) => {
124
+ const gap = ' '.repeat(TAB_GAP)
125
+ let text = segments.slice(start, end).join(gap)
126
+ if (start > 0) text = `${S_TAB_MORE}${gap}${text}`
127
+ if (end < n) text += `${gap}${S_TAB_MORE_RIGHT}`
128
+ text += ' '.repeat(groupSepWidth(groups, start, end))
129
+ // 复用 clack 的终端宽度处理,中文分类不能按字符串 length 计算。
130
+ return !wrapTextWithPrefix({ columns: maxWidth }, text, '').includes('\n')
166
131
  }
167
132
 
168
133
  let start = idx
169
134
  let end = idx + 1
170
135
  // 当前段已经超宽:不再扩张,避免把邻 Tab 挤没
171
- if (used(start, end) > maxWidth) {
136
+ if (!fits(start, end)) {
172
137
  return { start, end, showLeft: start > 0, showRight: end < n }
173
138
  }
174
139
 
175
140
  let grew = true
176
141
  while (grew) {
177
142
  grew = false
178
- if (end < n && used(start, end + 1) <= maxWidth) {
143
+ if (end < n && fits(start, end + 1)) {
179
144
  end += 1
180
145
  grew = true
181
146
  }
182
- if (start > 0 && used(start - 1, end) <= maxWidth) {
147
+ if (start > 0 && fits(start - 1, end)) {
183
148
  start -= 1
184
149
  grew = true
185
150
  }
@@ -330,7 +295,7 @@ function renderTabBar(segments, activeIndex, win, tabs, selectedValues) {
330
295
 
331
296
  /**
332
297
  * 打开顶部 Tab + 下方多选。
333
- * tabs 为空时不进交互,直接返回 [](调用方应先过滤空包)。
298
+ * tabs 为空时不进交互,直接返回 [](调用方应先过滤空分类)。
334
299
  *
335
300
  * @param {object} opts
336
301
  * @param {string} opts.message
@@ -380,13 +345,11 @@ export function tabMultiselect(opts) {
380
345
  const contentWidth = Math.max(cols - (withGuide ? 10 : 6), 20)
381
346
  const segments = tabBarSegments(this.tabs, selected)
382
347
  const groups = this.tabs.map(tab => tab.group)
383
- const win = tabWindow(segments, this.tabIndex, contentWidth, groups)
348
+ const win = tabWindow(segments, this.tabIndex, contentWidth - 2, groups)
384
349
  const tabLine = renderTabBar(segments, this.tabIndex, win, this.tabs, selected)
385
350
 
386
351
  const focused = this.currentOptions[this.cursor]
387
- const focusedType = itemTypeMeta(focused?.type)
388
- const descRaw = (focused?.description || '').trim() || '(无说明)'
389
- const desc = `${focusedType.title} · ${descRaw}`
352
+ const desc = (focused?.description || '').trim() || '(无说明)'
390
353
  const instructions = [
391
354
  `${styleText('dim', '←/→')} 切 Tab`,
392
355
  `${styleText('dim', '↑/↓')} 移动`,
@@ -421,7 +384,6 @@ export function tabMultiselect(opts) {
421
384
  label: opt.label ?? String(opt.value ?? ''),
422
385
  selected: selected.includes(opt.value),
423
386
  active,
424
- prefix: formatItemTypePrefix(opt.type),
425
387
  }),
426
388
  })
427
389