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.
- package/package.json +1 -1
- package/src/create.js +50 -3
- package/src/sync.js +228 -130
- package/src/utils.js +4 -0
- package/templates/claude-global/CLAUDE.md +81 -6
- package/templates/claude-global/rules/dwy-postgres.md +89 -0
- package/templates/claude-global/rules/dwy-python-backend.md +492 -0
- package/templates/claude-global/rules/dwy-python-core.md +94 -634
- package/templates/claude-global/rules/dwy-python-testing.md +115 -13
- package/templates/claude-global/rules/dwy-redis.md +88 -0
- package/templates/claude-global/rules/dwy-vue-core.md +341 -645
- package/templates/claude-global/rules/dwy-vue-pinia.md +47 -34
- package/templates/claude-global/rules/dwy-vue-testing.md +120 -55
- package/templates/claude-global/skills/dwy-deploy-audit/references/checks-network.md +1 -1
- package/templates/claude-global/skills/dwy-deploy-audit/scripts/check_nginx.sh +1 -1
- package/templates/claude-global/skills/dwy-dolphindb/SKILL.md +22 -15
- package/templates/claude-global/skills/dwy-dolphindb/evals/evals.json +23 -0
- package/templates/claude-global/skills/dwy-dolphindb/references/dolphindb-rules.md +100 -1
- package/templates/claude-global/skills/dwy-ekit/SKILL.md +142 -276
- package/templates/claude-global/skills/dwy-eui/SKILL.md +264 -182
- package/templates/claude-global/skills/dwy-fullstack-scaffold/SKILL.md +117 -129
- package/templates/claude-global/skills/dwy-git-commit/SKILL.md +125 -0
- package/templates/claude-global/skills/dwy-publish/SKILL.md +1 -1
- package/templates/claude-global/{rules/dwy-tech-stack.md → skills/dwy-tech-stack/SKILL.md} +4 -3
- package/templates/project/fullstack-monorepo/CLAUDE.md.hbs +81 -0
- package/templates/project/fullstack-monorepo/backend/.python-version +1 -0
- package/templates/project/fullstack-monorepo/backend/CLAUDE.md.hbs +68 -0
- package/templates/project/fullstack-monorepo/backend/Dockerfile.dev +23 -0
- package/templates/project/fullstack-monorepo/backend/alembic/env.py.hbs +68 -0
- package/templates/project/fullstack-monorepo/backend/alembic/script.py.mako +26 -0
- package/templates/project/fullstack-monorepo/backend/alembic.ini.hbs +43 -0
- package/templates/project/fullstack-monorepo/backend/packages/dwy-shared/pyproject.toml +16 -0
- package/templates/project/fullstack-monorepo/backend/packages/dwy-shared/src/dwy_shared/__init__.py +12 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/pyproject.toml.hbs +20 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/__init__.py.hbs +40 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/config.py +9 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/models.py.hbs +17 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/protocols.py +14 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/router.py.hbs +22 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/schemas.py +21 -0
- package/templates/project/fullstack-monorepo/backend/packages/{{scopePrefix}}-{{initialDomain}}/src/{{scopePrefix}}_{{initialDomain}}/service.py +11 -0
- package/templates/project/fullstack-monorepo/backend/pyproject.toml.hbs +84 -0
- package/templates/project/fullstack-monorepo/backend/src/app/__init__.py +3 -0
- package/templates/project/fullstack-monorepo/backend/src/app/config.py.hbs +21 -0
- package/templates/project/fullstack-monorepo/backend/src/app/exceptions.py +26 -0
- package/templates/project/fullstack-monorepo/backend/src/app/factories.py +16 -0
- package/templates/project/fullstack-monorepo/backend/src/app/infra/__init__.py +1 -0
- package/templates/project/fullstack-monorepo/backend/src/app/main.py.hbs +65 -0
- package/templates/project/fullstack-monorepo/backend/src/app/middleware/__init__.py +1 -0
- package/templates/project/fullstack-monorepo/backend/tests/__init__.py +0 -0
- package/templates/project/fullstack-monorepo/backend/tests/conftest.py.hbs +42 -0
- package/templates/project/fullstack-monorepo/dev.sh.hbs +83 -0
- package/templates/project/fullstack-monorepo/docker-compose.dev.yml.hbs +36 -0
- package/templates/project/fullstack-monorepo/frontend/CLAUDE.md.hbs +35 -0
- package/templates/project/fullstack-monorepo/frontend/Dockerfile +18 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/index.html.hbs +13 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/package.json.hbs +31 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/App.vue.hbs +10 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/api/client.ts.hbs +11 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/features/{{initialDomain}}/.gitkeep +0 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/index.css +12 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/main.ts +17 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/router/index.ts.hbs +24 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/stores/.gitkeep +0 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/types/.gitkeep +0 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/src/views/HomeView.vue.hbs +36 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/tsconfig.json +7 -0
- package/templates/project/fullstack-monorepo/frontend/apps/web/vite.config.ts.hbs +27 -0
- package/templates/project/fullstack-monorepo/frontend/nginx.conf +11 -0
- package/templates/project/fullstack-monorepo/frontend/package.json.hbs +13 -0
- package/templates/project/fullstack-monorepo/frontend/packages/core/package.json.hbs +15 -0
- package/templates/project/fullstack-monorepo/frontend/packages/core/src/index.ts +3 -0
- package/templates/project/fullstack-monorepo/frontend/packages/features/{{initialDomain}}/package.json.hbs +14 -0
- package/templates/project/fullstack-monorepo/frontend/packages/features/{{initialDomain}}/src/index.ts.hbs +16 -0
- package/templates/project/fullstack-monorepo/frontend/packages/features/{{initialDomain}}/src/pages/IndexPage.vue.hbs +9 -0
- package/templates/project/fullstack-monorepo/frontend/packages/ui/package.json.hbs +15 -0
- package/templates/project/fullstack-monorepo/frontend/packages/ui/src/index.ts +3 -0
- package/templates/project/fullstack-monorepo/frontend/pnpm-workspace.yaml.hbs +23 -0
- package/templates/project/fullstack-monorepo/frontend/tsconfig.base.json.hbs +23 -0
- package/templates/claude-global/rules/dwy-api-design.md +0 -110
- package/templates/claude-global/rules/dwy-git-commit-convention.md +0 -82
- package/templates/claude-global/rules/dwy-git-security.md +0 -53
- package/templates/claude-global/rules/dwy-payload-limits.md +0 -186
- package/templates/claude-global/rules/dwy-prefer-open-source.md +0 -32
- package/templates/claude-global/rules/dwy-project-structure.md +0 -181
- package/templates/claude-global/rules/dwy-python-fastapi.md +0 -272
- package/templates/claude-global/rules/dwy-python-orm.md +0 -61
- package/templates/claude-global/rules/dwy-python-security.md +0 -636
- package/templates/claude-global/rules/dwy-server-security.md +0 -518
- package/templates/claude-global/rules/dwy-vue-api.md +0 -146
- package/templates/claude-global/skills/dwy-ekit/references/data.md +0 -135
- package/templates/claude-global/skills/dwy-ekit/references/form.md +0 -68
- package/templates/claude-global/skills/dwy-ekit/references/request.md +0 -183
- package/templates/claude-global/skills/dwy-ekit/references/ui.md +0 -109
- package/templates/claude-global/skills/dwy-vue-core/SKILL.md +0 -890
- /package/templates/{claude-global/commands → project/fullstack-monorepo/backend/alembic/versions}/.gitkeep +0 -0
package/package.json
CHANGED
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: '
|
|
30
|
-
{ name: '
|
|
31
|
-
{ name: '
|
|
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:
|
|
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:
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
133
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
363
|
+
let selected
|
|
276
364
|
|
|
277
365
|
if (!reselect && hasAnyExisting) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
syncedCount += await
|
|
305
|
-
syncedCount += await
|
|
306
|
-
syncedCount += await
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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}
|
|
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(
|
|
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)
|