create-dwy 0.7.1 → 0.9.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 (96) hide show
  1. package/package.json +1 -1
  2. package/src/create.js +50 -3
  3. package/src/sync.js +228 -130
  4. package/src/utils.js +4 -0
  5. package/templates/claude-global/CLAUDE.md +81 -6
  6. package/templates/claude-global/rules/dwy-postgres.md +89 -0
  7. package/templates/claude-global/rules/dwy-python-backend.md +492 -0
  8. package/templates/claude-global/rules/dwy-python-core.md +94 -634
  9. package/templates/claude-global/rules/dwy-python-testing.md +115 -13
  10. package/templates/claude-global/rules/dwy-redis.md +88 -0
  11. package/templates/claude-global/rules/dwy-vue-core.md +341 -645
  12. package/templates/claude-global/rules/dwy-vue-pinia.md +47 -34
  13. package/templates/claude-global/rules/dwy-vue-testing.md +120 -55
  14. package/templates/claude-global/skills/dwy-deploy-audit/references/checks-network.md +1 -1
  15. package/templates/claude-global/skills/dwy-deploy-audit/scripts/check_nginx.sh +1 -1
  16. package/templates/claude-global/skills/dwy-dolphindb/SKILL.md +22 -15
  17. package/templates/claude-global/skills/dwy-dolphindb/evals/evals.json +23 -0
  18. package/templates/claude-global/skills/dwy-dolphindb/references/dolphindb-rules.md +100 -1
  19. package/templates/claude-global/skills/dwy-ekit/SKILL.md +142 -276
  20. package/templates/claude-global/skills/dwy-eui/SKILL.md +264 -182
  21. package/templates/claude-global/skills/dwy-fullstack-scaffold/SKILL.md +117 -129
  22. package/templates/claude-global/skills/dwy-git-commit/SKILL.md +125 -0
  23. package/templates/claude-global/skills/dwy-publish/SKILL.md +1 -1
  24. package/templates/claude-global/{rules/dwy-tech-stack.md → skills/dwy-tech-stack/SKILL.md} +4 -3
  25. package/templates/project/fullstack-monorepo/CLAUDE.md.hbs +81 -0
  26. package/templates/project/fullstack-monorepo/backend/.python-version +1 -0
  27. package/templates/project/fullstack-monorepo/backend/CLAUDE.md.hbs +68 -0
  28. package/templates/project/fullstack-monorepo/backend/Dockerfile.dev +23 -0
  29. package/templates/project/fullstack-monorepo/backend/alembic/env.py.hbs +68 -0
  30. package/templates/project/fullstack-monorepo/backend/alembic/script.py.mako +26 -0
  31. package/templates/project/fullstack-monorepo/backend/alembic.ini.hbs +43 -0
  32. package/templates/project/fullstack-monorepo/backend/packages/dwy-shared/pyproject.toml +16 -0
  33. package/templates/project/fullstack-monorepo/backend/packages/dwy-shared/src/dwy_shared/__init__.py +12 -0
  34. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/pyproject.toml.hbs +20 -0
  35. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/__init__.py.hbs +40 -0
  36. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/config.py +9 -0
  37. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/models.py.hbs +17 -0
  38. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/protocols.py +14 -0
  39. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/router.py.hbs +22 -0
  40. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/schemas.py +21 -0
  41. package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/service.py +11 -0
  42. package/templates/project/fullstack-monorepo/backend/pyproject.toml.hbs +84 -0
  43. package/templates/project/fullstack-monorepo/backend/src/app/__init__.py +3 -0
  44. package/templates/project/fullstack-monorepo/backend/src/app/config.py.hbs +21 -0
  45. package/templates/project/fullstack-monorepo/backend/src/app/exceptions.py +26 -0
  46. package/templates/project/fullstack-monorepo/backend/src/app/factories.py +16 -0
  47. package/templates/project/fullstack-monorepo/backend/src/app/infra/__init__.py +1 -0
  48. package/templates/project/fullstack-monorepo/backend/src/app/main.py.hbs +65 -0
  49. package/templates/project/fullstack-monorepo/backend/src/app/middleware/__init__.py +1 -0
  50. package/templates/project/fullstack-monorepo/backend/tests/__init__.py +0 -0
  51. package/templates/project/fullstack-monorepo/backend/tests/conftest.py.hbs +42 -0
  52. package/templates/project/fullstack-monorepo/dev.sh.hbs +83 -0
  53. package/templates/project/fullstack-monorepo/docker-compose.dev.yml.hbs +36 -0
  54. package/templates/project/fullstack-monorepo/frontend/CLAUDE.md.hbs +35 -0
  55. package/templates/project/fullstack-monorepo/frontend/Dockerfile +18 -0
  56. package/templates/project/fullstack-monorepo/frontend/apps/web/index.html.hbs +13 -0
  57. package/templates/project/fullstack-monorepo/frontend/apps/web/package.json.hbs +31 -0
  58. package/templates/project/fullstack-monorepo/frontend/apps/web/src/App.vue.hbs +10 -0
  59. package/templates/project/fullstack-monorepo/frontend/apps/web/src/api/client.ts.hbs +11 -0
  60. package/templates/project/fullstack-monorepo/frontend/apps/web/src/features/{{initialDomain}}/.gitkeep +0 -0
  61. package/templates/project/fullstack-monorepo/frontend/apps/web/src/index.css +12 -0
  62. package/templates/project/fullstack-monorepo/frontend/apps/web/src/main.ts +17 -0
  63. package/templates/project/fullstack-monorepo/frontend/apps/web/src/router/index.ts.hbs +24 -0
  64. package/templates/project/fullstack-monorepo/frontend/apps/web/src/stores/.gitkeep +0 -0
  65. package/templates/project/fullstack-monorepo/frontend/apps/web/src/types/.gitkeep +0 -0
  66. package/templates/project/fullstack-monorepo/frontend/apps/web/src/views/HomeView.vue.hbs +36 -0
  67. package/templates/project/fullstack-monorepo/frontend/apps/web/tsconfig.json +7 -0
  68. package/templates/project/fullstack-monorepo/frontend/apps/web/vite.config.ts.hbs +27 -0
  69. package/templates/project/fullstack-monorepo/frontend/nginx.conf +11 -0
  70. package/templates/project/fullstack-monorepo/frontend/package.json.hbs +13 -0
  71. package/templates/project/fullstack-monorepo/frontend/packages/core/package.json.hbs +15 -0
  72. package/templates/project/fullstack-monorepo/frontend/packages/core/src/index.ts +3 -0
  73. package/templates/project/fullstack-monorepo/frontend/packages/features/{{initialDomain}}/package.json.hbs +14 -0
  74. package/templates/project/fullstack-monorepo/frontend/packages/features/{{initialDomain}}/src/index.ts.hbs +16 -0
  75. package/templates/project/fullstack-monorepo/frontend/packages/features/{{initialDomain}}/src/pages/IndexPage.vue.hbs +9 -0
  76. package/templates/project/fullstack-monorepo/frontend/packages/ui/package.json.hbs +15 -0
  77. package/templates/project/fullstack-monorepo/frontend/packages/ui/src/index.ts +3 -0
  78. package/templates/project/fullstack-monorepo/frontend/pnpm-workspace.yaml.hbs +23 -0
  79. package/templates/project/fullstack-monorepo/frontend/tsconfig.base.json.hbs +23 -0
  80. package/templates/claude-global/rules/dwy-api-design.md +0 -110
  81. package/templates/claude-global/rules/dwy-git-commit-convention.md +0 -82
  82. package/templates/claude-global/rules/dwy-git-security.md +0 -53
  83. package/templates/claude-global/rules/dwy-payload-limits.md +0 -186
  84. package/templates/claude-global/rules/dwy-prefer-open-source.md +0 -32
  85. package/templates/claude-global/rules/dwy-project-structure.md +0 -181
  86. package/templates/claude-global/rules/dwy-python-fastapi.md +0 -272
  87. package/templates/claude-global/rules/dwy-python-orm.md +0 -61
  88. package/templates/claude-global/rules/dwy-python-security.md +0 -636
  89. package/templates/claude-global/rules/dwy-server-security.md +0 -518
  90. package/templates/claude-global/rules/dwy-vue-api.md +0 -146
  91. package/templates/claude-global/skills/dwy-ekit/references/data.md +0 -135
  92. package/templates/claude-global/skills/dwy-ekit/references/form.md +0 -68
  93. package/templates/claude-global/skills/dwy-ekit/references/request.md +0 -183
  94. package/templates/claude-global/skills/dwy-ekit/references/ui.md +0 -109
  95. package/templates/claude-global/skills/dwy-vue-core/SKILL.md +0 -890
  96. /package/templates/{claude-global/commands → project/fullstack-monorepo/backend/alembic/versions}/.gitkeep +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "项目脚手架与 Claude Code 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
package/src/create.js CHANGED
@@ -26,9 +26,10 @@ export async function createProject(name) {
26
26
  name: 'template',
27
27
  message: '选择项目模板:',
28
28
  choices: [
29
- { name: 'web — Vue 3 前端 (pnpm monorepo, eui + ekit + Tailwind)', value: 'web' },
30
- { name: 'backend — FastAPI 后端 (eapi + PostgreSQL + Redis + Docker)', value: 'backend' },
31
- { name: 'mobile — 移动端 (暂未实现)', value: 'mobile', disabled: '即将推出' },
29
+ { name: 'fullstack-monorepo — Vue 3 + FastAPI 全栈 monorepo (域分包 + Provider 模式)', value: 'fullstack-monorepo' },
30
+ { name: 'web — Vue 3 前端 (pnpm monorepo, eui + ekit + Tailwind)', value: 'web' },
31
+ { name: 'backend — FastAPI 后端 (eapi + PostgreSQL + Redis + Docker)', value: 'backend' },
32
+ { name: 'mobile — 移动端 (暂未实现)', value: 'mobile', disabled: '即将推出' },
32
33
  ],
33
34
  },
34
35
  ])
@@ -76,6 +77,39 @@ export async function createProject(name) {
76
77
  ])
77
78
  }
78
79
 
80
+ if (common.template === 'fullstack-monorepo') {
81
+ extra = await inquirer.prompt([
82
+ {
83
+ type: 'input',
84
+ name: 'scopePrefix',
85
+ message: '业务包前缀 (生成 packages/<prefix>-<domain>/):',
86
+ default: 'app',
87
+ validate: (v) => /^[a-z][a-z0-9]*$/.test(v) || '只允许小写字母开头,字母数字',
88
+ },
89
+ {
90
+ type: 'input',
91
+ name: 'initialDomain',
92
+ message: '初始业务域 (示例域名):',
93
+ default: 'core',
94
+ validate: (v) => /^[a-z][a-z0-9]*$/.test(v) || '只允许小写字母开头,字母数字',
95
+ },
96
+ {
97
+ type: 'confirm',
98
+ name: 'includeFrontend',
99
+ message: '是否生成 frontend?',
100
+ default: true,
101
+ },
102
+ {
103
+ type: 'input',
104
+ name: 'portPrefix',
105
+ message: '端口前缀数字 (1-9,避免与其他项目冲突,如 2 → 28001/25173/25432/26379):',
106
+ default: '2',
107
+ validate: (v) => /^[1-9]$/.test(v) || '只允许 1-9 的单个数字',
108
+ },
109
+ ])
110
+ extra.apiProxy = `http://127.0.0.1:${extra.portPrefix}8001`
111
+ }
112
+
79
113
  const answers = { ...common, ...extra }
80
114
 
81
115
  const context = {
@@ -99,6 +133,10 @@ export async function createProject(name) {
99
133
  await fs.ensureDir(destDir)
100
134
  await renderTemplate(templateDir, destDir, context)
101
135
 
136
+ if (context.template === 'fullstack-monorepo' && context.includeFrontend === false) {
137
+ await fs.remove(path.join(destDir, 'frontend'))
138
+ }
139
+
102
140
  console.log(chalk.green(`\n✅ 项目 ${context.projectName} 创建成功!\n`))
103
141
  console.log(chalk.gray('Next steps:'))
104
142
  console.log(chalk.gray(` cd ${context.projectName}`))
@@ -114,4 +152,13 @@ export async function createProject(name) {
114
152
  console.log(chalk.gray(' pnpm install'))
115
153
  console.log(chalk.gray(' pnpm dev'))
116
154
  }
155
+
156
+ if (context.template === 'fullstack-monorepo') {
157
+ console.log(chalk.gray(' cp .env.example .env'))
158
+ console.log(chalk.gray(' cd backend && uv sync --dev && cd ..'))
159
+ if (context.includeFrontend) {
160
+ console.log(chalk.gray(' cd frontend && pnpm install && cd ..'))
161
+ }
162
+ console.log(chalk.gray(' ./dev.sh'))
163
+ }
117
164
  }
package/src/sync.js CHANGED
@@ -4,6 +4,13 @@ import os from 'os'
4
4
  import inquirer from 'inquirer'
5
5
  import { ensureRepoCache, chalk } from './utils.js'
6
6
 
7
+ const CATEGORIES = [
8
+ { key: 'skills', label: 'Skills' },
9
+ { key: 'rules', label: 'Rules' },
10
+ { key: 'commands', label: 'Commands' },
11
+ { key: 'hooks', label: 'Hooks' },
12
+ ]
13
+
7
14
  function extractDescription(content) {
8
15
  const match = content.match(/^---\s*\n([\s\S]*?)\n---/)
9
16
  if (!match) return ''
@@ -18,23 +25,19 @@ async function scanSkills(sourceDir) {
18
25
 
19
26
  const entries = await fs.readdir(skillsDir, { withFileTypes: true })
20
27
  const skills = []
21
-
22
28
  for (const entry of entries) {
23
29
  if (!entry.isDirectory()) continue
24
30
  const skillPath = path.join(skillsDir, entry.name)
25
31
  const skillMd = path.join(skillPath, 'SKILL.md')
26
32
  if (!await fs.pathExists(skillMd)) continue
27
-
28
33
  const content = await fs.readFile(skillMd, 'utf-8')
29
- const description = extractDescription(content)
30
34
  skills.push({
31
35
  name: entry.name,
32
- description: description || '(无描述)',
36
+ description: extractDescription(content) || '(无描述)',
33
37
  sourcePath: skillPath,
34
38
  type: 'skill',
35
39
  })
36
40
  }
37
-
38
41
  return skills.sort((a, b) => a.name.localeCompare(b.name))
39
42
  }
40
43
 
@@ -44,20 +47,17 @@ async function scanRules(sourceDir) {
44
47
 
45
48
  const entries = await fs.readdir(rulesDir, { withFileTypes: true })
46
49
  const rules = []
47
-
48
50
  for (const entry of entries) {
49
51
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue
50
52
  const rulePath = path.join(rulesDir, entry.name)
51
53
  const content = await fs.readFile(rulePath, 'utf-8')
52
- const description = extractDescription(content)
53
54
  rules.push({
54
55
  name: entry.name,
55
- description: description || '(无描述)',
56
+ description: extractDescription(content) || '(无描述)',
56
57
  sourcePath: rulePath,
57
58
  type: 'rule',
58
59
  })
59
60
  }
60
-
61
61
  return rules.sort((a, b) => a.name.localeCompare(b.name))
62
62
  }
63
63
 
@@ -66,19 +66,15 @@ async function scanCommands(sourceDir) {
66
66
  if (!await fs.pathExists(commandsDir)) return []
67
67
 
68
68
  const entries = await fs.readdir(commandsDir, { withFileTypes: true })
69
- const commands = []
70
-
71
- for (const entry of entries) {
72
- if (entry.name === '.gitkeep') continue
73
- commands.push({
74
- name: entry.name,
75
- description: entry.isDirectory() ? '命令目录' : '命令文件',
76
- sourcePath: path.join(commandsDir, entry.name),
69
+ return entries
70
+ .filter(e => e.name !== '.gitkeep')
71
+ .map(e => ({
72
+ name: e.name,
73
+ description: e.isDirectory() ? '命令目录' : '命令文件',
74
+ sourcePath: path.join(commandsDir, e.name),
77
75
  type: 'command',
78
- })
79
- }
80
-
81
- return commands.sort((a, b) => a.name.localeCompare(b.name))
76
+ }))
77
+ .sort((a, b) => a.name.localeCompare(b.name))
82
78
  }
83
79
 
84
80
  async function scanHooks(sourceDir) {
@@ -86,40 +82,15 @@ async function scanHooks(sourceDir) {
86
82
  if (!await fs.pathExists(hooksDir)) return []
87
83
 
88
84
  const entries = await fs.readdir(hooksDir, { withFileTypes: true })
89
- const hooks = []
90
-
91
- for (const entry of entries) {
92
- if (entry.name === '.gitkeep') continue
93
- hooks.push({
94
- name: entry.name,
95
- description: entry.isDirectory() ? '钩子目录' : '钩子脚本',
96
- sourcePath: path.join(hooksDir, entry.name),
85
+ return entries
86
+ .filter(e => e.name !== '.gitkeep')
87
+ .map(e => ({
88
+ name: e.name,
89
+ description: e.isDirectory() ? '钩子目录' : '钩子脚本',
90
+ sourcePath: path.join(hooksDir, e.name),
97
91
  type: 'hook',
98
- })
99
- }
100
-
101
- return hooks.sort((a, b) => a.name.localeCompare(b.name))
102
- }
103
-
104
- async function promptSelection(items, category, existingNames = new Set()) {
105
- if (items.length === 0) return []
106
-
107
- const { selected } = await inquirer.prompt([
108
- {
109
- type: 'checkbox',
110
- name: 'selected',
111
- message: `选择要同步的 ${category}(空格勾选,回车确认):`,
112
- choices: items.map(item => ({
113
- name: `${chalk.cyan(item.name)} ${chalk.gray('- ' + item.description.slice(0, 60))}${item.description.length > 60 ? '...' : ''}`,
114
- value: item,
115
- short: item.name,
116
- checked: existingNames.has(item.name),
117
- })),
118
- pageSize: 15,
119
- },
120
- ])
121
-
122
- return selected
92
+ }))
93
+ .sort((a, b) => a.name.localeCompare(b.name))
123
94
  }
124
95
 
125
96
  async function scanExisting(projectTargetDir, typePlural) {
@@ -129,11 +100,87 @@ async function scanExisting(projectTargetDir, typePlural) {
129
100
  return new Set(entries.filter(name => name !== '.gitkeep' && name !== '.DS_Store'))
130
101
  }
131
102
 
132
- function logAction(dryRun, label) {
133
- console.log(chalk.green(` ${dryRun ? '[dry-run] ' : '✓ '}${label}`))
103
+ async function promptSelection(items, label, defaultNames) {
104
+ if (items.length === 0) return []
105
+ const { selected } = await inquirer.prompt([{
106
+ type: 'checkbox',
107
+ name: 'selected',
108
+ message: `选择 ${label}(空格勾选,回车确认):`,
109
+ choices: items.map(item => ({
110
+ name: `${chalk.cyan(item.name)} ${chalk.gray('- ' + item.description.slice(0, 60))}${item.description.length > 60 ? '...' : ''}`,
111
+ value: item,
112
+ short: item.name,
113
+ checked: defaultNames.has(item.name),
114
+ })),
115
+ pageSize: 15,
116
+ }])
117
+ return selected
118
+ }
119
+
120
+ /**
121
+ * 交互式选择:每步可回退到上一步,最后总览支持任意类别重选。
122
+ * 返回 { skills, rules, commands, hooks } 或 null(取消)。
123
+ */
124
+ async function interactiveSelect(scans, existing) {
125
+ const sel = {}
126
+
127
+ // 第一轮:顺序遍历,每步给导航
128
+ let i = 0
129
+ while (i < CATEGORIES.length) {
130
+ const { key, label } = CATEGORIES[i]
131
+ const defaultNames = sel[key]
132
+ ? new Set(sel[key].map(x => x.name))
133
+ : existing[key]
134
+ sel[key] = await promptSelection(scans[key], label, defaultNames)
135
+
136
+ if (i === CATEGORIES.length - 1) break
137
+
138
+ const nextLabel = CATEGORIES[i + 1].label
139
+ const prevLabel = i > 0 ? CATEGORIES[i - 1].label : null
140
+ const { nav } = await inquirer.prompt([{
141
+ type: 'list',
142
+ name: 'nav',
143
+ message: `${label} 已选 ${sel[key].length} 项。下一步:`,
144
+ choices: [
145
+ { name: `→ 继续选 ${nextLabel}`, value: 'next' },
146
+ ...(prevLabel ? [{ name: `← 返回重选 ${prevLabel}`, value: 'back' }] : []),
147
+ { name: '✗ 取消同步', value: 'cancel' },
148
+ ],
149
+ default: 'next',
150
+ }])
151
+ if (nav === 'cancel') return null
152
+ i = nav === 'back' ? i - 1 : i + 1
153
+ }
154
+
155
+ // 第二轮:总览 + 任意重选
156
+ while (true) {
157
+ console.log(chalk.gray('\n选择汇总:'))
158
+ for (const { key, label } of CATEGORIES) {
159
+ console.log(chalk.gray(` ${label.padEnd(10)} ${sel[key].length} 项`))
160
+ }
161
+ const { final } = await inquirer.prompt([{
162
+ type: 'list',
163
+ name: 'final',
164
+ message: '确认提交还是重选?',
165
+ choices: [
166
+ { name: '✓ 确认提交', value: 'confirm' },
167
+ ...CATEGORIES.map(c => ({ name: `重选 ${c.label}`, value: c.key })),
168
+ { name: '✗ 取消同步', value: 'cancel' },
169
+ ],
170
+ default: 'confirm',
171
+ }])
172
+ if (final === 'confirm') return sel
173
+ if (final === 'cancel') return null
174
+ const cat = CATEGORIES.find(c => c.key === final)
175
+ sel[final] = await promptSelection(scans[final], cat.label, new Set(sel[final].map(x => x.name)))
176
+ }
177
+ }
178
+
179
+ function logAction(dryRun, label, color = 'green', prefix = '✓') {
180
+ console.log(chalk[color](` ${dryRun ? '[dry-run] ' : prefix + ' '}${label}`))
134
181
  }
135
182
 
136
- async function syncSelectedItems(items, targetDir, dryRun) {
183
+ async function copyItems(items, targetDir, dryRun) {
137
184
  for (const item of items) {
138
185
  const dest = path.join(targetDir, item.type + 's', item.name)
139
186
  if (!dryRun) {
@@ -145,19 +192,76 @@ async function syncSelectedItems(items, targetDir, dryRun) {
145
192
  return items.length
146
193
  }
147
194
 
148
- async function syncSettings(sourceDir, targetDir, dryRun) {
195
+ async function copyHooks(items, targetDir, dryRun) {
196
+ if (items.length === 0) return 0
197
+ const hooksTargetDir = path.join(targetDir, 'hooks')
198
+ if (!dryRun) await fs.ensureDir(hooksTargetDir)
199
+ for (const hook of items) {
200
+ const dest = path.join(hooksTargetDir, hook.name)
201
+ if (!dryRun) {
202
+ await fs.copy(hook.sourcePath, dest, { overwrite: true })
203
+ await fs.chmod(dest, 0o755)
204
+ }
205
+ logAction(dryRun, `hooks/${hook.name}`)
206
+ }
207
+ return items.length
208
+ }
209
+
210
+ async function removeUnselected(typePlural, existingNames, selectedNames, targetDir, dryRun) {
211
+ let count = 0
212
+ for (const name of existingNames) {
213
+ if (selectedNames.has(name)) continue
214
+ const target = path.join(targetDir, typePlural, name)
215
+ if (!dryRun) await fs.remove(target)
216
+ logAction(dryRun, `${typePlural}/${name}`, 'red', '×')
217
+ count++
218
+ }
219
+ return count
220
+ }
221
+
222
+ /**
223
+ * 过滤模板 settings.json 中未选中 hooks 的引用。
224
+ * 识别规则:command 字段中匹配 $CLAUDE_PROJECT_DIR/.claude/hooks/<name> 的项。
225
+ */
226
+ function filterSettingsByHooks(newSettings, selectedHookNames) {
227
+ if (!newSettings.hooks) return newSettings
228
+ const cloned = JSON.parse(JSON.stringify(newSettings))
229
+ const hookPathRegex = /\.claude\/hooks\/([^\s'"]+)/
230
+
231
+ for (const event of Object.keys(cloned.hooks)) {
232
+ const configs = cloned.hooks[event] || []
233
+ const kept = []
234
+ for (const cfg of configs) {
235
+ const remainingHooks = (cfg.hooks || []).filter(h => {
236
+ const m = h.command?.match(hookPathRegex)
237
+ if (!m) return true
238
+ return selectedHookNames.has(m[1])
239
+ })
240
+ if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
241
+ }
242
+ if (kept.length > 0) cloned.hooks[event] = kept
243
+ else delete cloned.hooks[event]
244
+ }
245
+
246
+ if (Object.keys(cloned.hooks).length === 0) delete cloned.hooks
247
+ return cloned
248
+ }
249
+
250
+ async function syncSettings(sourceDir, targetDir, selectedHookNames, dryRun) {
149
251
  const settingsSrc = path.join(sourceDir, 'settings.json')
150
252
  if (!await fs.pathExists(settingsSrc)) return 0
151
253
 
152
254
  const settingsDest = path.join(targetDir, 'settings.json')
153
255
  const newSettings = await fs.readJson(settingsSrc)
256
+ const filteredNew = filterSettingsByHooks(newSettings, selectedHookNames)
154
257
 
155
258
  let merged
156
259
  let action
157
260
  if (await fs.pathExists(settingsDest)) {
158
261
  const existing = await fs.readJson(settingsDest)
159
262
  merged = { ...existing }
160
- for (const [key, value] of Object.entries(newSettings)) {
263
+ for (const [key, value] of Object.entries(filteredNew)) {
264
+ if (key === 'hooks') continue
161
265
  if (key in merged && typeof merged[key] === 'object' && !Array.isArray(merged[key])
162
266
  && typeof value === 'object' && !Array.isArray(value)) {
163
267
  merged[key] = { ...merged[key], ...value }
@@ -165,9 +269,12 @@ async function syncSettings(sourceDir, targetDir, dryRun) {
165
269
  merged[key] = value
166
270
  }
167
271
  }
272
+ // hooks 字段由模板独占:模板有则覆盖,模板没有则删除
273
+ if (filteredNew.hooks) merged.hooks = filteredNew.hooks
274
+ else delete merged.hooks
168
275
  action = 'merged'
169
276
  } else {
170
- merged = newSettings
277
+ merged = filteredNew
171
278
  action = 'created'
172
279
  }
173
280
 
@@ -179,27 +286,11 @@ async function syncSettings(sourceDir, targetDir, dryRun) {
179
286
  async function syncClaudeMd(sourceDir, targetDir, dryRun) {
180
287
  const claudeMdSrc = path.join(sourceDir, 'CLAUDE.md')
181
288
  if (!await fs.pathExists(claudeMdSrc)) return 0
182
-
183
289
  if (!dryRun) await fs.copy(claudeMdSrc, path.join(targetDir, 'CLAUDE.md'), { overwrite: true })
184
290
  logAction(dryRun, 'CLAUDE.md')
185
291
  return 1
186
292
  }
187
293
 
188
- async function syncHooks(items, targetDir, dryRun) {
189
- if (items.length === 0) return 0
190
- const hooksTargetDir = path.join(targetDir, 'hooks')
191
- if (!dryRun) await fs.ensureDir(hooksTargetDir)
192
- for (const hook of items) {
193
- const dest = path.join(hooksTargetDir, hook.name)
194
- if (!dryRun) {
195
- await fs.copy(hook.sourcePath, dest, { overwrite: true })
196
- await fs.chmod(dest, 0o755)
197
- }
198
- logAction(dryRun, `hooks/${hook.name}`)
199
- }
200
- return items.length
201
- }
202
-
203
294
  async function resolveSourceDir() {
204
295
  const localTemplate = path.join(process.cwd(), 'claude-cli', 'templates', 'claude-global')
205
296
  if (await fs.pathExists(localTemplate)) {
@@ -212,7 +303,7 @@ async function resolveSourceDir() {
212
303
 
213
304
  /**
214
305
  * 入口:`dwy claude sync [target]`
215
- * target=undefined 全量同步:项目 .claude/ + 全局 CLAUDE.md
306
+ * target=undefined 同步项目 .claude/(不动全局 CLAUDE.md)
216
307
  * target='md' 仅同步 CLAUDE.md → 全局 ~/.claude/
217
308
  */
218
309
  export async function syncClaude({ target, reselect = false, dryRun = false } = {}) {
@@ -222,17 +313,13 @@ export async function syncClaude({ target, reselect = false, dryRun = false } =
222
313
  process.exit(1)
223
314
  }
224
315
 
225
- const globalTargetDir = path.join(os.homedir(), '.claude')
226
-
227
316
  if (target === 'md') {
317
+ const globalTargetDir = path.join(os.homedir(), '.claude')
228
318
  if (!dryRun) await fs.ensureDir(globalTargetDir)
229
319
  console.log(chalk.blue(`\nSyncing CLAUDE.md to ${globalTargetDir}...\n`))
230
320
  const synced = await syncClaudeMd(sourceDir, globalTargetDir, dryRun)
231
- if (!synced) {
232
- console.log(chalk.yellow('CLAUDE.md not found in templates, nothing to sync.'))
233
- } else {
234
- console.log(chalk.blue(`\n${dryRun ? 'Dry-run complete.' : 'Done.'}`))
235
- }
321
+ if (!synced) console.log(chalk.yellow('CLAUDE.md not found in templates, nothing to sync.'))
322
+ else console.log(chalk.blue(`\n${dryRun ? 'Dry-run complete.' : 'Done.'}`))
236
323
  return
237
324
  }
238
325
 
@@ -245,71 +332,82 @@ export async function syncClaude({ target, reselect = false, dryRun = false } =
245
332
  const projectTargetDir = path.join(process.cwd(), '.claude')
246
333
 
247
334
  if (!await fs.pathExists(projectTargetDir)) {
248
- const { shouldCreate } = await inquirer.prompt([
249
- {
250
- type: 'confirm',
251
- name: 'shouldCreate',
252
- message: `当前目录 ${chalk.yellow(process.cwd())} 未找到 .claude 目录,是否在此创建?`,
253
- default: false,
254
- },
255
- ])
335
+ const { shouldCreate } = await inquirer.prompt([{
336
+ type: 'confirm',
337
+ name: 'shouldCreate',
338
+ message: `当前目录 ${chalk.yellow(process.cwd())} 未找到 .claude 目录,是否在此创建?`,
339
+ default: false,
340
+ }])
256
341
  if (!shouldCreate) {
257
342
  console.log(chalk.yellow('\n已取消同步。'))
258
343
  return
259
344
  }
260
345
  }
261
346
 
262
- const skills = await scanSkills(sourceDir)
263
- const rules = await scanRules(sourceDir)
264
- const commands = await scanCommands(sourceDir)
265
- const hooks = await scanHooks(sourceDir)
266
-
267
- console.log(chalk.yellow(`Found ${skills.length} skills, ${rules.length} rules, ${commands.length} commands, ${hooks.length} hooks\n`))
347
+ const scans = {
348
+ skills: await scanSkills(sourceDir),
349
+ rules: await scanRules(sourceDir),
350
+ commands: await scanCommands(sourceDir),
351
+ hooks: await scanHooks(sourceDir),
352
+ }
353
+ console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.commands.length} commands, ${scans.hooks.length} hooks\n`))
268
354
 
269
- const existingSkills = await scanExisting(projectTargetDir, 'skills')
270
- const existingRules = await scanExisting(projectTargetDir, 'rules')
271
- const existingCommands = await scanExisting(projectTargetDir, 'commands')
272
- const existingHooks = await scanExisting(projectTargetDir, 'hooks')
273
- const hasAnyExisting = existingSkills.size + existingRules.size + existingCommands.size + existingHooks.size > 0
355
+ const existing = {
356
+ skills: await scanExisting(projectTargetDir, 'skills'),
357
+ rules: await scanExisting(projectTargetDir, 'rules'),
358
+ commands: await scanExisting(projectTargetDir, 'commands'),
359
+ hooks: await scanExisting(projectTargetDir, 'hooks'),
360
+ }
361
+ const hasAnyExisting = existing.skills.size + existing.rules.size + existing.commands.size + existing.hooks.size > 0
274
362
 
275
- let selectedSkills, selectedRules, selectedCommands, selectedHooks
363
+ let selected
276
364
 
277
365
  if (!reselect && hasAnyExisting) {
278
- selectedSkills = skills.filter(s => existingSkills.has(s.name))
279
- selectedRules = rules.filter(r => existingRules.has(r.name))
280
- selectedCommands = commands.filter(c => existingCommands.has(c.name))
281
- // hooks 与 settings.json 引用配套:未装过则装全部模板,已装过则按已选覆盖
282
- selectedHooks = existingHooks.size > 0
283
- ? hooks.filter(h => existingHooks.has(h.name))
284
- : hooks
285
- console.log(chalk.gray(`使用已选覆盖同步(如需调整请加 -i / --reselect):`))
286
- console.log(chalk.gray(` ${selectedSkills.length} skills, ${selectedRules.length} rules, ${selectedCommands.length} commands, ${selectedHooks.length} hooks\n`))
366
+ // 缓存模式:模板 ∩ 已选
367
+ selected = {
368
+ skills: scans.skills.filter(s => existing.skills.has(s.name)),
369
+ rules: scans.rules.filter(r => existing.rules.has(r.name)),
370
+ commands: scans.commands.filter(c => existing.commands.has(c.name)),
371
+ hooks: existing.hooks.size > 0
372
+ ? scans.hooks.filter(h => existing.hooks.has(h.name))
373
+ : scans.hooks,
374
+ }
375
+ console.log(chalk.gray('使用已选覆盖同步(如需调整请加 -i / --reselect):'))
376
+ console.log(chalk.gray(` ${selected.skills.length} skills, ${selected.rules.length} rules, ${selected.commands.length} commands, ${selected.hooks.length} hooks\n`))
287
377
  } else {
288
- if (!reselect && !hasAnyExisting) {
289
- console.log(chalk.gray('首次同步,进入交互式选择...\n'))
378
+ if (!reselect && !hasAnyExisting) console.log(chalk.gray('首次同步,进入交互式选择...\n'))
379
+ const result = await interactiveSelect(scans, existing)
380
+ if (result === null) {
381
+ console.log(chalk.yellow('\n已取消同步。'))
382
+ return
290
383
  }
291
- selectedSkills = await promptSelection(skills, 'Skills', existingSkills)
292
- selectedRules = await promptSelection(rules, 'Rules', existingRules)
293
- selectedCommands = await promptSelection(commands, 'Commands', existingCommands)
294
- selectedHooks = await promptSelection(hooks, 'Hooks', existingHooks)
384
+ selected = result
295
385
  }
296
386
 
297
- console.log(chalk.blue(`\n${dryRun ? '[dry-run] ' : ''}Syncing to ${projectTargetDir}...\n`))
387
+ const selectedHookNames = new Set(selected.hooks.map(h => h.name))
298
388
 
389
+ console.log(chalk.blue(`\n${dryRun ? '[dry-run] ' : ''}Syncing to ${projectTargetDir}...\n`))
299
390
  if (!dryRun) await fs.ensureDir(projectTargetDir)
300
391
 
301
392
  let syncedCount = 0
302
- syncedCount += await syncSelectedItems(selectedSkills, projectTargetDir, dryRun)
303
- syncedCount += await syncSelectedItems(selectedRules, projectTargetDir, dryRun)
304
- syncedCount += await syncSelectedItems(selectedCommands, projectTargetDir, dryRun)
305
- syncedCount += await syncSettings(sourceDir, projectTargetDir, dryRun)
306
- syncedCount += await syncHooks(selectedHooks, projectTargetDir, dryRun)
307
-
308
- if (!dryRun) await fs.ensureDir(globalTargetDir)
309
- syncedCount += await syncClaudeMd(sourceDir, globalTargetDir, dryRun)
393
+ let removedCount = 0
394
+
395
+ syncedCount += await copyItems(selected.skills, projectTargetDir, dryRun)
396
+ syncedCount += await copyItems(selected.rules, projectTargetDir, dryRun)
397
+ syncedCount += await copyItems(selected.commands, projectTargetDir, dryRun)
398
+ syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames, dryRun)
399
+ syncedCount += await copyHooks(selected.hooks, projectTargetDir, dryRun)
400
+
401
+ // 删除已存在但本次未勾选的(仅 reselect 模式:缓存模式默认 selected ⊇ existing 不会触发)
402
+ if (reselect) {
403
+ removedCount += await removeUnselected('skills', existing.skills, new Set(selected.skills.map(s => s.name)), projectTargetDir, dryRun)
404
+ removedCount += await removeUnselected('rules', existing.rules, new Set(selected.rules.map(r => r.name)), projectTargetDir, dryRun)
405
+ removedCount += await removeUnselected('commands', existing.commands, new Set(selected.commands.map(c => c.name)), projectTargetDir, dryRun)
406
+ removedCount += await removeUnselected('hooks', existing.hooks, selectedHookNames, projectTargetDir, dryRun)
407
+ }
310
408
 
311
- console.log(chalk.blue(`\n${dryRun ? 'Dry-run complete.' : 'Done!'} ${syncedCount} items.`))
409
+ console.log(chalk.blue(`\n${dryRun ? 'Dry-run complete.' : 'Done!'} ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
312
410
  console.log(chalk.gray(` Project config → ${projectTargetDir}`))
313
411
  console.log(chalk.gray(` Hooks → ${path.join(projectTargetDir, 'hooks')}`))
314
- console.log(chalk.gray(` CLAUDE.md → ${globalTargetDir}`))
412
+ console.log(chalk.gray(' CLAUDE.md → 跳过(用 `dwy claude sync md` 单独同步到 ~/.claude/)'))
315
413
  }
package/src/utils.js CHANGED
@@ -34,6 +34,10 @@ export async function renderTemplate(templateDir, destDir, context) {
34
34
  const srcPath = path.join(templateDir, entry.name)
35
35
  let destName = entry.name
36
36
 
37
+ if (destName.includes('{{')) {
38
+ destName = Handlebars.compile(destName)(context)
39
+ }
40
+
37
41
  if (entry.isDirectory()) {
38
42
  const destPath = path.join(destDir, destName)
39
43
  await fs.ensureDir(destPath)