create-dwy 0.14.3 → 0.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -2
- package/bin/index.js +28 -2
- package/package.json +1 -1
- package/src/sync-all.js +219 -6
- package/src/sync-codex.js +57 -4
- package/src/sync-cursor.js +10 -3
- package/src/sync-opencode.js +13 -3
- package/src/sync.js +96 -9
- package/src/utils.js +20 -0
- package/templates/ai-tools/rules/iOS/dwy-swift-core.md +75 -0
- package/templates/ai-tools/skills//345/205/203/345/267/245/345/205/267/dwy-shared/SKILL.md +4 -4
- package/templates/ai-tools/skills//350/204/232/346/211/213/346/236/266/dwy-fullstack-scaffold/SKILL.md +1 -1
- package/templates/ai-tools/rules/iOS/dwy-swift-style.md +0 -103
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# create-dwy
|
|
2
2
|
|
|
3
|
+
## 0.14.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- `dwy` 同步模板改回运行时从仓库缓存拉取,默认更新 `~/.dwy/cache/dwy` 后再读取模板,不再依赖 CLI 包内静态模板。
|
|
8
|
+
- 新增项目级同步状态文件 `/.dwy/sync-state.json`,记录各平台上次由 `dwy` 托管的 skills / rules / commands / hooks。
|
|
9
|
+
- 当模板仓库移除已同步项时,再次执行 `dwy` 会罗列缺失项并让用户选择是否删除;未确认删除的旧项会继续保留。
|
|
10
|
+
|
|
11
|
+
## 0.14.4
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 将 iOS 规则 `dwy-swift-style.md` 重命名为 `dwy-swift-core.md`,并重构为精简版规范。
|
|
16
|
+
- 新增规则级配置收口约束,要求可配置项统一在 `AppConfig` 管理。
|
|
17
|
+
- 加强国际化规则:默认要求中文/英文支持,统一 `localizationBundle + loc() + LocalizationManager + AppLanguage` 实施要点。
|
|
18
|
+
- 模板同步改为仅使用 CLI 内置 `dwy-cli/templates/ai-tools`,不再读取外部缓存目录或按 `origin` 拉取远端模板仓库。
|
|
19
|
+
|
|
3
20
|
## 0.14.3
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -27,9 +44,9 @@
|
|
|
27
44
|
### Minor Changes
|
|
28
45
|
|
|
29
46
|
- CLI 重构为单一交互式入口,移除旧的命令式 `create` / `sync` / `claude` / `codex` 流程。
|
|
30
|
-
- 模板源统一收敛到 `/Users/chances/WebstormProjects/dwy-shared/
|
|
47
|
+
- 模板源统一收敛到 `/Users/chances/WebstormProjects/dwy-shared/dwy-cli/templates/ai-tools`,随 CLI 包一起发布,不再运行时拉取远端模板仓库。
|
|
31
48
|
- 新增多平台适配层:支持 `Claude Code`、`Codex`、`Cursor`、`OpenCode` 的 rules / skills / commands / hooks 同步。
|
|
32
|
-
- 基准规范文件 `/Users/chances/WebstormProjects/dwy-shared/
|
|
49
|
+
- 基准规范文件 `/Users/chances/WebstormProjects/dwy-shared/dwy-cli/templates/ai-tools/CLAUDE.md` 现在会按平台分别同步到 `.claude/CLAUDE.md`、根 `AGENTS.md`、`.cursor/rules/00-dwy-global.mdc`。
|
|
33
50
|
- 平台未选中时会清理 dwy 托管内容,同时保留用户自定义文件、用户自定义 hook 配置和 `AGENTS.md` 非托管内容。
|
|
34
51
|
|
|
35
52
|
## 0.13.8
|
package/bin/index.js
CHANGED
|
@@ -7,6 +7,32 @@ import { syncAll } from '../src/sync-all.js'
|
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
9
9
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'))
|
|
10
|
+
const argv = process.argv.slice(2)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
function printHelp() {
|
|
13
|
+
const lines = [
|
|
14
|
+
`dwy ${pkg.version}`,
|
|
15
|
+
'',
|
|
16
|
+
'Usage:',
|
|
17
|
+
' dwy 运行交互式同步',
|
|
18
|
+
' dwy sync 运行交互式同步',
|
|
19
|
+
' dwy --help 显示本说明',
|
|
20
|
+
' dwy -h 显示本说明',
|
|
21
|
+
' dwy --version 显示当前版本',
|
|
22
|
+
' dwy -V 显示当前版本',
|
|
23
|
+
'',
|
|
24
|
+
]
|
|
25
|
+
console.log(lines.join('\n'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
|
|
29
|
+
printHelp()
|
|
30
|
+
} else if (argv.includes('--version') || argv.includes('-V')) {
|
|
31
|
+
console.log(pkg.version)
|
|
32
|
+
} else if (argv.length === 0 || argv[0] === 'sync') {
|
|
33
|
+
await syncAll()
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`未知命令: ${argv.join(' ')}`)
|
|
36
|
+
printHelp()
|
|
37
|
+
process.exitCode = 1
|
|
38
|
+
}
|
package/package.json
CHANGED
package/src/sync-all.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
scanExistingCodexSkills,
|
|
23
23
|
syncCodex,
|
|
24
24
|
} from './sync-codex.js'
|
|
25
|
-
import { syncCursor } from './sync-cursor.js'
|
|
25
|
+
import { scanExistingCursorRules, syncCursor } from './sync-cursor.js'
|
|
26
26
|
import { syncOpenCode } from './sync-opencode.js'
|
|
27
27
|
|
|
28
28
|
const PLATFORM_OPTIONS = [
|
|
@@ -33,6 +33,25 @@ const PLATFORM_OPTIONS = [
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const CURSOR_BASELINE_RULE_NAME = '00-dwy-global.mdc'
|
|
36
|
+
const SYNC_STATE_PATH = ['.dwy', 'sync-state.json']
|
|
37
|
+
const PLATFORM_SYNC_TYPES = {
|
|
38
|
+
claude: ['skills', 'rules', 'commands', 'hooks'],
|
|
39
|
+
codex: ['skills', 'rules', 'hooks'],
|
|
40
|
+
cursor: ['rules'],
|
|
41
|
+
opencode: ['skills', 'rules', 'commands'],
|
|
42
|
+
}
|
|
43
|
+
const PLATFORM_LABELS = {
|
|
44
|
+
claude: 'Claude Code',
|
|
45
|
+
codex: 'Codex',
|
|
46
|
+
cursor: 'Cursor',
|
|
47
|
+
opencode: 'OpenCode',
|
|
48
|
+
}
|
|
49
|
+
const TYPE_LABELS = {
|
|
50
|
+
skills: 'skills',
|
|
51
|
+
rules: 'rules',
|
|
52
|
+
commands: 'commands',
|
|
53
|
+
hooks: 'hooks',
|
|
54
|
+
}
|
|
36
55
|
|
|
37
56
|
function unionSets(...sets) {
|
|
38
57
|
return new Set(sets.flatMap(set => [...set]))
|
|
@@ -111,6 +130,172 @@ function buildCursorBaselineContent(baseline) {
|
|
|
111
130
|
].join('\n')
|
|
112
131
|
}
|
|
113
132
|
|
|
133
|
+
function createEmptyPlatformState() {
|
|
134
|
+
return {
|
|
135
|
+
skills: [],
|
|
136
|
+
rules: [],
|
|
137
|
+
commands: [],
|
|
138
|
+
hooks: [],
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function loadSyncState(projectDir) {
|
|
143
|
+
const statePath = path.join(projectDir, ...SYNC_STATE_PATH)
|
|
144
|
+
if (!await fs.pathExists(statePath)) {
|
|
145
|
+
return {
|
|
146
|
+
version: 1,
|
|
147
|
+
platforms: {},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const state = await fs.readJson(statePath)
|
|
152
|
+
return {
|
|
153
|
+
version: 1,
|
|
154
|
+
platforms: state.platforms || {},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function saveSyncState(projectDir, state) {
|
|
159
|
+
const statePath = path.join(projectDir, ...SYNC_STATE_PATH)
|
|
160
|
+
await fs.ensureDir(path.dirname(statePath))
|
|
161
|
+
await fs.writeJson(statePath, state, { spaces: 2 })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getCurrentTemplateNames(scans) {
|
|
165
|
+
return {
|
|
166
|
+
skills: new Set(scans.skills.map(item => item.name)),
|
|
167
|
+
rules: new Set(scans.rules.map(item => item.name)),
|
|
168
|
+
commands: new Set(scans.commands.map(item => item.name)),
|
|
169
|
+
hooks: new Set(scans.hooks.map(item => item.name)),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function collectCurrentManagedNames(projectDir) {
|
|
174
|
+
const claudeDir = path.join(projectDir, '.claude')
|
|
175
|
+
const openCodeDir = path.join(projectDir, '.opencode')
|
|
176
|
+
const agentsMdPath = path.join(projectDir, 'AGENTS.md')
|
|
177
|
+
const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
claude: {
|
|
181
|
+
skills: await scanExisting(claudeDir, 'skills'),
|
|
182
|
+
rules: await scanExisting(claudeDir, 'rules'),
|
|
183
|
+
commands: await scanExisting(claudeDir, 'commands'),
|
|
184
|
+
hooks: await scanExisting(claudeDir, 'hooks'),
|
|
185
|
+
},
|
|
186
|
+
codex: {
|
|
187
|
+
skills: await scanExistingCodexSkills(projectDir),
|
|
188
|
+
rules: parseManagedRuleNames(agentsMdContent),
|
|
189
|
+
hooks: await scanExistingCodexHooks(projectDir),
|
|
190
|
+
},
|
|
191
|
+
cursor: {
|
|
192
|
+
rules: await scanExistingCursorRules(projectDir),
|
|
193
|
+
},
|
|
194
|
+
opencode: {
|
|
195
|
+
skills: await scanExisting(openCodeDir, 'skills'),
|
|
196
|
+
rules: parseManagedRuleNames(agentsMdContent),
|
|
197
|
+
commands: await scanExisting(openCodeDir, 'commands'),
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeRemovalInput(removals = {}) {
|
|
203
|
+
const normalized = {}
|
|
204
|
+
for (const [platform, types] of Object.entries(removals)) {
|
|
205
|
+
normalized[platform] = {}
|
|
206
|
+
for (const [type, names] of Object.entries(types || {})) {
|
|
207
|
+
normalized[platform][type] = new Set(Array.isArray(names) ? names : [...names])
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return normalized
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createEmptyRemovalState() {
|
|
214
|
+
return Object.fromEntries(
|
|
215
|
+
Object.keys(PLATFORM_SYNC_TYPES).map(platform => [platform, {}]),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function collectStaleEntries(projectDir, scans, syncState, selectedPlatforms) {
|
|
220
|
+
const currentTemplateNames = getCurrentTemplateNames(scans)
|
|
221
|
+
const currentManaged = await collectCurrentManagedNames(projectDir)
|
|
222
|
+
const staleEntries = createEmptyRemovalState()
|
|
223
|
+
|
|
224
|
+
for (const platform of selectedPlatforms) {
|
|
225
|
+
const previous = syncState.platforms?.[platform] || {}
|
|
226
|
+
const platformManaged = currentManaged[platform] || {}
|
|
227
|
+
|
|
228
|
+
for (const type of PLATFORM_SYNC_TYPES[platform] || []) {
|
|
229
|
+
const previousNames = previous[type] || []
|
|
230
|
+
const currentNames = currentTemplateNames[type] || new Set()
|
|
231
|
+
const managedNames = platformManaged[type] || new Set()
|
|
232
|
+
const staleNames = []
|
|
233
|
+
|
|
234
|
+
for (const name of previousNames) {
|
|
235
|
+
if (currentNames.has(name)) continue
|
|
236
|
+
if (type === 'rules' && platform === 'cursor') {
|
|
237
|
+
if (!managedNames.has(buildCursorRuleName(name))) continue
|
|
238
|
+
} else if (!managedNames.has(name)) {
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
staleNames.push(name)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (staleNames.length > 0) staleEntries[platform][type] = staleNames.sort((a, b) => a.localeCompare(b, 'zh'))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return staleEntries
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function promptStaleRemovals(staleEntries) {
|
|
252
|
+
const approvals = createEmptyRemovalState()
|
|
253
|
+
|
|
254
|
+
for (const platform of Object.keys(PLATFORM_SYNC_TYPES)) {
|
|
255
|
+
for (const type of PLATFORM_SYNC_TYPES[platform]) {
|
|
256
|
+
const names = staleEntries[platform]?.[type] || []
|
|
257
|
+
if (names.length === 0) continue
|
|
258
|
+
|
|
259
|
+
console.log(chalk.yellow(`\n检测到 ${PLATFORM_LABELS[platform]} 已同步但模板仓库已移除的 ${TYPE_LABELS[type]}:`))
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
console.log(chalk.gray(` - ${name}`))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const selected = await multiselect({
|
|
265
|
+
message: `选择要删除的 ${PLATFORM_LABELS[platform]} ${TYPE_LABELS[type]}`,
|
|
266
|
+
options: names.map(name => ({ value: name, label: name })),
|
|
267
|
+
initialValues: names,
|
|
268
|
+
required: false,
|
|
269
|
+
})
|
|
270
|
+
if (isCancel(selected)) return null
|
|
271
|
+
approvals[platform][type] = new Set(selected)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return approvals
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getPreservedStaleNames(staleEntries, approvedRemovals, platform, type) {
|
|
279
|
+
const staleNames = staleEntries[platform]?.[type] || []
|
|
280
|
+
const approvedNames = approvedRemovals[platform]?.[type] || new Set()
|
|
281
|
+
return new Set(staleNames.filter(name => !approvedNames.has(name)))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildNextPlatformState(selected, staleEntries, approvedRemovals, platform) {
|
|
285
|
+
const state = createEmptyPlatformState()
|
|
286
|
+
|
|
287
|
+
for (const type of PLATFORM_SYNC_TYPES[platform] || []) {
|
|
288
|
+
const selectedNames = new Set((selected[type] || []).map(item => item.name))
|
|
289
|
+
for (const name of staleEntries[platform]?.[type] || []) {
|
|
290
|
+
const approvedNames = approvedRemovals[platform]?.[type] || new Set()
|
|
291
|
+
if (!approvedNames.has(name)) selectedNames.add(name)
|
|
292
|
+
}
|
|
293
|
+
state[type] = [...selectedNames].sort((a, b) => a.localeCompare(b, 'zh'))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return state
|
|
297
|
+
}
|
|
298
|
+
|
|
114
299
|
async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix) {
|
|
115
300
|
let removedCount = 0
|
|
116
301
|
|
|
@@ -267,7 +452,6 @@ async function cleanupCursorPlatform(projectDir, scans, baseline) {
|
|
|
267
452
|
)
|
|
268
453
|
|
|
269
454
|
let removedCount = 0
|
|
270
|
-
const managedRuleNames = new Set(scans.rules.map(rule => buildCursorRuleName(rule.name)))
|
|
271
455
|
for (const name of existingManagedNames) {
|
|
272
456
|
if (name === CURSOR_BASELINE_RULE_NAME) continue
|
|
273
457
|
await fs.remove(path.join(rulesDir, name))
|
|
@@ -316,6 +500,9 @@ async function cleanupOpenCodePlatform(projectDir, scans) {
|
|
|
316
500
|
export async function syncAll({
|
|
317
501
|
sourceDir: sourceDirOverride,
|
|
318
502
|
projectDir: projectDirOverride,
|
|
503
|
+
selected: selectedOverride,
|
|
504
|
+
selectedPlatforms: selectedPlatformsOverride,
|
|
505
|
+
staleRemovals: staleRemovalsOverride,
|
|
319
506
|
} = {}) {
|
|
320
507
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
321
508
|
if (!await fs.pathExists(sourceDir)) {
|
|
@@ -346,28 +533,39 @@ export async function syncAll({
|
|
|
346
533
|
console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.commands.length} commands, ${scans.hooks.length} hooks\n`))
|
|
347
534
|
|
|
348
535
|
const existing = await scanCombinedExisting(projectDir)
|
|
349
|
-
|
|
536
|
+
const skipInitPrompt = !!(selectedOverride || selectedPlatformsOverride)
|
|
537
|
+
if (!skipInitPrompt && !await shouldInitialize(projectDir, existing)) {
|
|
350
538
|
console.log(chalk.yellow('\n已取消同步。'))
|
|
351
539
|
return
|
|
352
540
|
}
|
|
353
541
|
|
|
354
542
|
const hasAnyExisting = existing.skills.size + existing.rules.size + existing.commands.size + existing.hooks.size > 0
|
|
355
|
-
if (!hasAnyExisting) {
|
|
543
|
+
if (!hasAnyExisting && !selectedOverride) {
|
|
356
544
|
console.log(chalk.gray('首次同步,进入交互式选择...\n'))
|
|
357
545
|
}
|
|
358
546
|
|
|
359
|
-
const selected = await interactiveSelect(scans, existing)
|
|
547
|
+
const selected = selectedOverride || await interactiveSelect(scans, existing)
|
|
360
548
|
if (selected === null) {
|
|
361
549
|
console.log(chalk.yellow('\n已取消同步。'))
|
|
362
550
|
return
|
|
363
551
|
}
|
|
364
552
|
|
|
365
|
-
const selectedPlatforms = await selectPlatforms()
|
|
553
|
+
const selectedPlatforms = selectedPlatformsOverride || await selectPlatforms()
|
|
366
554
|
if (selectedPlatforms === null) {
|
|
367
555
|
console.log(chalk.yellow('\n已取消同步。'))
|
|
368
556
|
return
|
|
369
557
|
}
|
|
370
558
|
|
|
559
|
+
const syncState = await loadSyncState(projectDir)
|
|
560
|
+
const staleEntries = await collectStaleEntries(projectDir, scans, syncState, selectedPlatforms)
|
|
561
|
+
const approvedStaleRemovals = staleRemovalsOverride
|
|
562
|
+
? normalizeRemovalInput(staleRemovalsOverride)
|
|
563
|
+
: await promptStaleRemovals(staleEntries)
|
|
564
|
+
if (approvedStaleRemovals === null) {
|
|
565
|
+
console.log(chalk.yellow('\n已取消同步。'))
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
371
569
|
let syncedAnySupportedPlatform = false
|
|
372
570
|
|
|
373
571
|
if (selectedPlatforms.includes('claude')) {
|
|
@@ -377,6 +575,7 @@ export async function syncAll({
|
|
|
377
575
|
sourceDir,
|
|
378
576
|
projectDir,
|
|
379
577
|
selected,
|
|
578
|
+
staleRemovals: approvedStaleRemovals.claude,
|
|
380
579
|
})
|
|
381
580
|
}
|
|
382
581
|
|
|
@@ -387,6 +586,8 @@ export async function syncAll({
|
|
|
387
586
|
sourceDir,
|
|
388
587
|
projectDir,
|
|
389
588
|
selected,
|
|
589
|
+
staleRemovals: approvedStaleRemovals.codex,
|
|
590
|
+
preserveMissingRules: getPreservedStaleNames(staleEntries, approvedStaleRemovals, 'codex', 'rules'),
|
|
390
591
|
})
|
|
391
592
|
}
|
|
392
593
|
|
|
@@ -396,6 +597,7 @@ export async function syncAll({
|
|
|
396
597
|
sourceDir,
|
|
397
598
|
projectDir,
|
|
398
599
|
selected,
|
|
600
|
+
staleRemovals: approvedStaleRemovals.cursor,
|
|
399
601
|
})
|
|
400
602
|
}
|
|
401
603
|
|
|
@@ -405,6 +607,8 @@ export async function syncAll({
|
|
|
405
607
|
sourceDir,
|
|
406
608
|
projectDir,
|
|
407
609
|
selected,
|
|
610
|
+
staleRemovals: approvedStaleRemovals.opencode,
|
|
611
|
+
preserveMissingRules: getPreservedStaleNames(staleEntries, approvedStaleRemovals, 'opencode', 'rules'),
|
|
408
612
|
})
|
|
409
613
|
}
|
|
410
614
|
|
|
@@ -428,6 +632,15 @@ export async function syncAll({
|
|
|
428
632
|
await cleanupOpenCodePlatform(projectDir, scans)
|
|
429
633
|
}
|
|
430
634
|
|
|
635
|
+
for (const platform of Object.keys(PLATFORM_SYNC_TYPES)) {
|
|
636
|
+
if (selectedPlatforms.includes(platform)) {
|
|
637
|
+
syncState.platforms[platform] = buildNextPlatformState(selected, staleEntries, approvedStaleRemovals, platform)
|
|
638
|
+
} else {
|
|
639
|
+
syncState.platforms[platform] = createEmptyPlatformState()
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
await saveSyncState(projectDir, syncState)
|
|
643
|
+
|
|
431
644
|
if (!syncedAnySupportedPlatform) {
|
|
432
645
|
console.log(chalk.yellow('未选择已接入平台,本次未执行同步。'))
|
|
433
646
|
}
|
package/src/sync-codex.js
CHANGED
|
@@ -60,6 +60,32 @@ export function parseManagedRuleNames(content) {
|
|
|
60
60
|
return names
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export function parseManagedRuleSections(content) {
|
|
64
|
+
const block = content.match(BLOCK_RE)
|
|
65
|
+
if (!block) return new Map()
|
|
66
|
+
|
|
67
|
+
const sections = new Map()
|
|
68
|
+
const blockContent = block[0]
|
|
69
|
+
const markerRe = /<!-- dwy-rule:\s*(.+?)\s*-->/g
|
|
70
|
+
const markers = []
|
|
71
|
+
let match
|
|
72
|
+
while ((match = markerRe.exec(blockContent))) {
|
|
73
|
+
markers.push({
|
|
74
|
+
name: match[1],
|
|
75
|
+
index: match.index,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const blockEndIndex = blockContent.lastIndexOf(BLOCK_END)
|
|
80
|
+
for (let i = 0; i < markers.length; i++) {
|
|
81
|
+
const start = markers[i].index
|
|
82
|
+
const end = i + 1 < markers.length ? markers[i + 1].index : blockEndIndex
|
|
83
|
+
sections.set(markers[i].name, blockContent.slice(start, end).trim())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return sections
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
function upsertManagedBlock(content, blockBody) {
|
|
64
90
|
if (!blockBody.trim()) {
|
|
65
91
|
if (!BLOCK_RE.test(content)) return content
|
|
@@ -86,12 +112,21 @@ function upsertBaselineBlock(content, baselineBody) {
|
|
|
86
112
|
return base ? `${block}\n\n${base}` : `${block}\n`
|
|
87
113
|
}
|
|
88
114
|
|
|
89
|
-
export async function syncProjectAgentsMd(sourceDir, projectDir, selectedRules
|
|
115
|
+
export async function syncProjectAgentsMd(sourceDir, projectDir, selectedRules, {
|
|
116
|
+
preservedRuleNames = new Set(),
|
|
117
|
+
} = {}) {
|
|
90
118
|
const dest = path.join(projectDir, 'AGENTS.md')
|
|
91
119
|
const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
|
|
92
120
|
const baselineBody = await readBaselineDoc(sourceDir)
|
|
121
|
+
const selectedRuleNames = new Set(selectedRules.map(rule => rule.name))
|
|
122
|
+
const existingSections = parseManagedRuleSections(current)
|
|
93
123
|
const sections = []
|
|
94
124
|
for (const rule of selectedRules) sections.push(await ruleToSection(rule))
|
|
125
|
+
for (const ruleName of preservedRuleNames) {
|
|
126
|
+
if (selectedRuleNames.has(ruleName)) continue
|
|
127
|
+
const existingSection = existingSections.get(ruleName)
|
|
128
|
+
if (existingSection) sections.push(existingSection)
|
|
129
|
+
}
|
|
95
130
|
const blockBody = sections.join('\n\n---\n\n')
|
|
96
131
|
|
|
97
132
|
const withBaseline = upsertBaselineBlock(current, baselineBody)
|
|
@@ -172,8 +207,16 @@ async function copyCodexHooks(hooks, proj) {
|
|
|
172
207
|
const dir = path.join(proj, '.codex', 'hooks')
|
|
173
208
|
await fs.ensureDir(dir)
|
|
174
209
|
for (const h of hooks) {
|
|
210
|
+
if (!await fs.pathExists(h.sourcePath)) {
|
|
211
|
+
logAction(`.codex/hooks/${h.name}`, 'yellow', '!')
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
175
214
|
const dest = path.join(dir, h.name)
|
|
176
215
|
await fs.copy(h.sourcePath, dest, { overwrite: true, filter: copyFilter })
|
|
216
|
+
if (!await fs.pathExists(dest)) {
|
|
217
|
+
logAction(`.codex/hooks/${h.name}`, 'yellow', '!')
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
177
220
|
await fs.chmod(dest, 0o755)
|
|
178
221
|
logAction(`.codex/hooks/${h.name}`)
|
|
179
222
|
}
|
|
@@ -278,6 +321,8 @@ export async function syncCodex({
|
|
|
278
321
|
sourceDir: sourceDirOverride,
|
|
279
322
|
projectDir,
|
|
280
323
|
selected,
|
|
324
|
+
staleRemovals = {},
|
|
325
|
+
preserveMissingRules = new Set(),
|
|
281
326
|
} = {}) {
|
|
282
327
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
283
328
|
if (!await fs.pathExists(sourceDir)) {
|
|
@@ -316,12 +361,20 @@ export async function syncCodex({
|
|
|
316
361
|
let removedCount = 0
|
|
317
362
|
|
|
318
363
|
syncedCount += await copySkillsFlat(syncedSelection.skills, proj)
|
|
319
|
-
syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules
|
|
364
|
+
syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules, {
|
|
365
|
+
preservedRuleNames: preserveMissingRules,
|
|
366
|
+
})
|
|
320
367
|
syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
|
|
321
368
|
syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames)
|
|
322
369
|
|
|
323
|
-
const skillTemplateNames = new Set(
|
|
324
|
-
|
|
370
|
+
const skillTemplateNames = new Set([
|
|
371
|
+
...scans.skills.map(s => s.name),
|
|
372
|
+
...(staleRemovals.skills || new Set()),
|
|
373
|
+
])
|
|
374
|
+
const hookTemplateNames = new Set([
|
|
375
|
+
...scans.hooks.map(h => h.name),
|
|
376
|
+
...(staleRemovals.hooks || new Set()),
|
|
377
|
+
])
|
|
325
378
|
removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
|
|
326
379
|
removedCount += await removeUnselectedFlat(['.codex', 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
|
|
327
380
|
|
package/src/sync-cursor.js
CHANGED
|
@@ -101,12 +101,13 @@ async function syncCursorBaselineRule(sourceDir, projectDir) {
|
|
|
101
101
|
return 1
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
async function removeUnselectedCursorRules(existingNames, selectedNames, projectDir) {
|
|
104
|
+
async function removeUnselectedCursorRules(existingNames, selectedNames, managedNames, projectDir) {
|
|
105
105
|
const rulesDir = path.join(projectDir, '.cursor', 'rules')
|
|
106
106
|
let removedCount = 0
|
|
107
107
|
|
|
108
108
|
for (const name of existingNames) {
|
|
109
109
|
if (selectedNames.has(name)) continue
|
|
110
|
+
if (!managedNames.has(name)) continue
|
|
110
111
|
await fs.remove(path.join(rulesDir, name))
|
|
111
112
|
logAction(`.cursor/rules/${name}`, 'red', '×')
|
|
112
113
|
removedCount++
|
|
@@ -115,7 +116,7 @@ async function removeUnselectedCursorRules(existingNames, selectedNames, project
|
|
|
115
116
|
return removedCount
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
async function scanExistingCursorRules(projectDir) {
|
|
119
|
+
export async function scanExistingCursorRules(projectDir) {
|
|
119
120
|
const rulesDir = path.join(projectDir, '.cursor', 'rules')
|
|
120
121
|
if (!await fs.pathExists(rulesDir)) return new Set()
|
|
121
122
|
const entries = await fs.readdir(rulesDir, { withFileTypes: true })
|
|
@@ -131,6 +132,7 @@ export async function syncCursor({
|
|
|
131
132
|
sourceDir: sourceDirOverride,
|
|
132
133
|
projectDir,
|
|
133
134
|
selected,
|
|
135
|
+
staleRemovals = {},
|
|
134
136
|
} = {}) {
|
|
135
137
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
136
138
|
if (!await fs.pathExists(sourceDir)) {
|
|
@@ -148,6 +150,11 @@ export async function syncCursor({
|
|
|
148
150
|
const syncedSelection = scans.rules.filter(rule => selectedRuleNames.has(rule.name))
|
|
149
151
|
const existingRules = await scanExistingCursorRules(proj)
|
|
150
152
|
const nextRuleNames = new Set(syncedSelection.map(rule => toCursorRuleName(rule.name)))
|
|
153
|
+
const managedRuleNames = new Set([
|
|
154
|
+
...scans.rules.map(rule => toCursorRuleName(rule.name)),
|
|
155
|
+
...[...(staleRemovals.rules || new Set())].map(ruleName => toCursorRuleName(ruleName)),
|
|
156
|
+
BASELINE_RULE_NAME,
|
|
157
|
+
])
|
|
151
158
|
|
|
152
159
|
let syncedCount = 0
|
|
153
160
|
let removedCount = 0
|
|
@@ -155,7 +162,7 @@ export async function syncCursor({
|
|
|
155
162
|
syncedCount += await syncCursorBaselineRule(sourceDir, proj)
|
|
156
163
|
syncedCount += await syncCursorRules(syncedSelection, proj)
|
|
157
164
|
nextRuleNames.add(BASELINE_RULE_NAME)
|
|
158
|
-
removedCount += await removeUnselectedCursorRules(existingRules, nextRuleNames, proj)
|
|
165
|
+
removedCount += await removeUnselectedCursorRules(existingRules, nextRuleNames, managedRuleNames, proj)
|
|
159
166
|
|
|
160
167
|
console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
|
|
161
168
|
console.log(chalk.gray(` Rules → ${path.join(proj, '.cursor', 'rules')}`))
|
package/src/sync-opencode.js
CHANGED
|
@@ -46,6 +46,8 @@ export async function syncOpenCode({
|
|
|
46
46
|
sourceDir: sourceDirOverride,
|
|
47
47
|
projectDir,
|
|
48
48
|
selected,
|
|
49
|
+
staleRemovals = {},
|
|
50
|
+
preserveMissingRules = new Set(),
|
|
49
51
|
} = {}) {
|
|
50
52
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
51
53
|
if (!await fs.pathExists(sourceDir)) {
|
|
@@ -80,7 +82,9 @@ export async function syncOpenCode({
|
|
|
80
82
|
let removedCount = 0
|
|
81
83
|
|
|
82
84
|
const agentsPath = path.join(proj, 'AGENTS.md')
|
|
83
|
-
syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules
|
|
85
|
+
syncedCount += await syncProjectAgentsMd(sourceDir, proj, syncedSelection.rules, {
|
|
86
|
+
preservedRuleNames: preserveMissingRules,
|
|
87
|
+
})
|
|
84
88
|
|
|
85
89
|
syncedCount += await copyItemsTo(openCodeDir, 'skills', syncedSelection.skills)
|
|
86
90
|
syncedCount += await copyItemsTo(openCodeDir, 'commands', syncedSelection.commands)
|
|
@@ -89,14 +93,20 @@ export async function syncOpenCode({
|
|
|
89
93
|
'skills',
|
|
90
94
|
existing.skills,
|
|
91
95
|
new Set(syncedSelection.skills.map(item => item.name)),
|
|
92
|
-
new Set(
|
|
96
|
+
new Set([
|
|
97
|
+
...scans.skills.map(item => item.name),
|
|
98
|
+
...(staleRemovals.skills || new Set()),
|
|
99
|
+
]),
|
|
93
100
|
proj,
|
|
94
101
|
)
|
|
95
102
|
removedCount += await removeUnselected(
|
|
96
103
|
'commands',
|
|
97
104
|
existing.commands,
|
|
98
105
|
new Set(syncedSelection.commands.map(item => item.name)),
|
|
99
|
-
new Set(
|
|
106
|
+
new Set([
|
|
107
|
+
...scans.commands.map(item => item.name),
|
|
108
|
+
...(staleRemovals.commands || new Set()),
|
|
109
|
+
]),
|
|
100
110
|
proj,
|
|
101
111
|
)
|
|
102
112
|
|
package/src/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { groupMultiselect, isCancel, select } from '@clack/prompts'
|
|
4
|
-
import {
|
|
4
|
+
import { chalk, DEFAULT_TEMPLATE_REPO_URL, getDwyHomeDir, runGit } from './utils.js'
|
|
5
5
|
|
|
6
6
|
const CATEGORIES = [
|
|
7
7
|
{ key: 'skills', label: 'Skills' },
|
|
@@ -21,6 +21,12 @@ const COPY_EXCLUDE_PATTERNS = [
|
|
|
21
21
|
/\.pyc$/,
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
const TEMPLATE_SOURCE_CANDIDATES = [
|
|
25
|
+
['dwy-cli', 'templates', 'ai-tools'],
|
|
26
|
+
['claude-cli', 'templates', 'ai-tools'],
|
|
27
|
+
['templates', 'ai-tools'],
|
|
28
|
+
]
|
|
29
|
+
|
|
24
30
|
export function copyFilter(src) {
|
|
25
31
|
return !COPY_EXCLUDE_PATTERNS.some(re => re.test(src))
|
|
26
32
|
}
|
|
@@ -307,6 +313,55 @@ export function logAction(label, color = 'green', prefix = '✓') {
|
|
|
307
313
|
console.log(chalk[color](` ${prefix} ${label}`))
|
|
308
314
|
}
|
|
309
315
|
|
|
316
|
+
function getTemplateRepoCacheDir() {
|
|
317
|
+
return path.join(getDwyHomeDir(), 'cache', 'dwy')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function resolveTemplateDirFromRepo(repoDir) {
|
|
321
|
+
for (const segments of TEMPLATE_SOURCE_CANDIDATES) {
|
|
322
|
+
const candidate = path.join(repoDir, ...segments)
|
|
323
|
+
if (await fs.pathExists(candidate)) return candidate
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
throw new Error(`ai-tools templates not found in repo: ${repoDir}`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function readGitOriginUrl(repoDir) {
|
|
330
|
+
try {
|
|
331
|
+
return await runGit(['-C', repoDir, 'remote', 'get-url', 'origin'])
|
|
332
|
+
} catch {
|
|
333
|
+
return ''
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function ensureTemplateRepoCache() {
|
|
338
|
+
const repoDir = getTemplateRepoCacheDir()
|
|
339
|
+
const repoRoot = path.dirname(repoDir)
|
|
340
|
+
await fs.ensureDir(repoRoot)
|
|
341
|
+
|
|
342
|
+
const hasGitDir = await fs.pathExists(path.join(repoDir, '.git'))
|
|
343
|
+
if (hasGitDir) {
|
|
344
|
+
const originUrl = await readGitOriginUrl(repoDir)
|
|
345
|
+
if (!originUrl || originUrl !== DEFAULT_TEMPLATE_REPO_URL) {
|
|
346
|
+
console.log(chalk.gray('缓存模板仓库 origin 已变更,重建缓存...'))
|
|
347
|
+
await fs.remove(repoDir)
|
|
348
|
+
}
|
|
349
|
+
} else if (await fs.pathExists(repoDir)) {
|
|
350
|
+
console.log(chalk.gray('缓存目录不是 git 仓库,重建缓存...'))
|
|
351
|
+
await fs.remove(repoDir)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
|
355
|
+
console.log(chalk.gray('拉取模板仓库...'))
|
|
356
|
+
await runGit(['clone', DEFAULT_TEMPLATE_REPO_URL, repoDir])
|
|
357
|
+
return repoDir
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
console.log(chalk.gray('更新模板仓库...'))
|
|
361
|
+
await runGit(['-C', repoDir, 'pull', '--ff-only'])
|
|
362
|
+
return repoDir
|
|
363
|
+
}
|
|
364
|
+
|
|
310
365
|
async function copyItems(items, targetDir) {
|
|
311
366
|
for (const item of items) {
|
|
312
367
|
const dest = path.join(targetDir, item.type + 's', item.name)
|
|
@@ -322,8 +377,16 @@ async function copyHooks(items, targetDir) {
|
|
|
322
377
|
const hooksTargetDir = path.join(targetDir, 'hooks')
|
|
323
378
|
await fs.ensureDir(hooksTargetDir)
|
|
324
379
|
for (const hook of items) {
|
|
380
|
+
if (!await fs.pathExists(hook.sourcePath)) {
|
|
381
|
+
logAction(`hooks/${hook.name}`, 'yellow', '!')
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
325
384
|
const dest = path.join(hooksTargetDir, hook.name)
|
|
326
385
|
await fs.copy(hook.sourcePath, dest, { overwrite: true, filter: copyFilter })
|
|
386
|
+
if (!await fs.pathExists(dest)) {
|
|
387
|
+
logAction(`hooks/${hook.name}`, 'yellow', '!')
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
327
390
|
await fs.chmod(dest, 0o755)
|
|
328
391
|
logAction(`hooks/${hook.name}`)
|
|
329
392
|
}
|
|
@@ -454,17 +517,23 @@ async function syncClaudeBaselineDoc(sourceDir, targetDir) {
|
|
|
454
517
|
}
|
|
455
518
|
|
|
456
519
|
export async function resolveSourceDir() {
|
|
457
|
-
const
|
|
458
|
-
if (
|
|
459
|
-
|
|
520
|
+
const explicitSourceDir = process.env.DWY_TEMPLATE_SOURCE_DIR
|
|
521
|
+
if (explicitSourceDir) {
|
|
522
|
+
if (!await fs.pathExists(explicitSourceDir)) {
|
|
523
|
+
throw new Error(`DWY_TEMPLATE_SOURCE_DIR not found: ${explicitSourceDir}`)
|
|
524
|
+
}
|
|
525
|
+
return explicitSourceDir
|
|
460
526
|
}
|
|
461
|
-
|
|
527
|
+
|
|
528
|
+
const repoDir = await ensureTemplateRepoCache()
|
|
529
|
+
return resolveTemplateDirFromRepo(repoDir)
|
|
462
530
|
}
|
|
463
531
|
|
|
464
532
|
export async function syncClaude({
|
|
465
533
|
sourceDir: sourceDirOverride,
|
|
466
534
|
projectDir,
|
|
467
535
|
selected,
|
|
536
|
+
staleRemovals = {},
|
|
468
537
|
} = {}) {
|
|
469
538
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
470
539
|
if (!await fs.pathExists(sourceDir)) {
|
|
@@ -481,6 +550,20 @@ export async function syncClaude({
|
|
|
481
550
|
commands: await scanCommands(sourceDir),
|
|
482
551
|
hooks: await scanHooks(sourceDir),
|
|
483
552
|
}
|
|
553
|
+
const availableHooks = []
|
|
554
|
+
for (const hook of scans.hooks) {
|
|
555
|
+
if (await fs.pathExists(hook.sourcePath)) {
|
|
556
|
+
availableHooks.push(hook)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (availableHooks.length < scans.hooks.length) {
|
|
560
|
+
const missing = scans.hooks
|
|
561
|
+
.filter(h => !availableHooks.includes(h))
|
|
562
|
+
.map(h => h.name)
|
|
563
|
+
.join('、')
|
|
564
|
+
logAction(`hooks skipped: ${missing}`, 'yellow', '!')
|
|
565
|
+
}
|
|
566
|
+
scans.hooks = availableHooks
|
|
484
567
|
console.log(chalk.yellow(`Found ${scans.skills.length} skills, ${scans.rules.length} rules, ${scans.commands.length} commands, ${scans.hooks.length} hooks\n`))
|
|
485
568
|
|
|
486
569
|
const existing = {
|
|
@@ -499,6 +582,10 @@ export async function syncClaude({
|
|
|
499
582
|
}
|
|
500
583
|
|
|
501
584
|
const selectedHookNames = new Set(syncedSelection.hooks.map(h => h.name))
|
|
585
|
+
const approvedStaleSkills = staleRemovals.skills || new Set()
|
|
586
|
+
const approvedStaleRules = staleRemovals.rules || new Set()
|
|
587
|
+
const approvedStaleCommands = staleRemovals.commands || new Set()
|
|
588
|
+
const approvedStaleHooks = staleRemovals.hooks || new Set()
|
|
502
589
|
|
|
503
590
|
console.log(chalk.blue(`\nSyncing to ${projectTargetDir}...\n`))
|
|
504
591
|
await fs.ensureDir(projectTargetDir)
|
|
@@ -513,10 +600,10 @@ export async function syncClaude({
|
|
|
513
600
|
syncedCount += await syncSettings(sourceDir, projectTargetDir, selectedHookNames)
|
|
514
601
|
syncedCount += await copyHooks(syncedSelection.hooks, projectTargetDir)
|
|
515
602
|
|
|
516
|
-
const skillTemplateNames = new Set(scans.skills.map(s => s.name))
|
|
517
|
-
const ruleTemplateNames = new Set(scans.rules.map(r => r.name))
|
|
518
|
-
const commandTemplateNames = new Set(scans.commands.map(c => c.name))
|
|
519
|
-
const hookTemplateNames = new Set(scans.hooks.map(h => h.name))
|
|
603
|
+
const skillTemplateNames = new Set([...scans.skills.map(s => s.name), ...approvedStaleSkills])
|
|
604
|
+
const ruleTemplateNames = new Set([...scans.rules.map(r => r.name), ...approvedStaleRules])
|
|
605
|
+
const commandTemplateNames = new Set([...scans.commands.map(c => c.name), ...approvedStaleCommands])
|
|
606
|
+
const hookTemplateNames = new Set([...scans.hooks.map(h => h.name), ...approvedStaleHooks])
|
|
520
607
|
removedCount += await removeUnselected('skills', existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, projectTargetDir)
|
|
521
608
|
removedCount += await removeUnselected('rules', existing.rules, new Set(syncedSelection.rules.map(r => r.name)), ruleTemplateNames, projectTargetDir)
|
|
522
609
|
removedCount += await removeUnselected('commands', existing.commands, new Set(syncedSelection.commands.map(c => c.name)), commandTemplateNames, projectTargetDir)
|
package/src/utils.js
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import path from 'path'
|
|
3
4
|
import Handlebars from 'handlebars'
|
|
4
5
|
import chalk from 'chalk'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
5
8
|
import { fileURLToPath } from 'url'
|
|
6
9
|
|
|
7
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
8
12
|
|
|
9
13
|
export const PACKAGE_ROOT = path.resolve(__dirname, '..')
|
|
14
|
+
export const DEFAULT_TEMPLATE_REPO_URL = process.env.DWY_REPO_URL || 'https://github.com/danweiyuancircle/dwy-ai-experience.git'
|
|
15
|
+
|
|
16
|
+
export function getDwyHomeDir() {
|
|
17
|
+
return process.env.DWY_HOME || path.join(os.homedir(), '.dwy')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runGit(args, options = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync('git', args, options)
|
|
23
|
+
return stdout.trim()
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const stderr = error.stderr?.trim()
|
|
26
|
+
const details = stderr ? `: ${stderr}` : ''
|
|
27
|
+
throw new Error(`git ${args.join(' ')} failed${details}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
export async function renderTemplate(templateDir, destDir, context) {
|
|
12
32
|
const entries = await fs.readdir(templateDir, { withFileTypes: true })
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: iOS 17+ SwiftUI 开发基线规则(全局适配、动画、国际化、注释)
|
|
3
|
+
paths:
|
|
4
|
+
- "**/*.swift"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## iOS 目标与写法
|
|
8
|
+
|
|
9
|
+
- iOS 最低版本 17.0+,不为 16 及以下做兼容分支。
|
|
10
|
+
- 统一 Observation + `@MainActor`,状态对象默认 `final class`。
|
|
11
|
+
|
|
12
|
+
```swift
|
|
13
|
+
@MainActor
|
|
14
|
+
@Observable
|
|
15
|
+
final class TaskListViewModel {}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 风格
|
|
19
|
+
|
|
20
|
+
- 4 空格缩进,不用 Tab。
|
|
21
|
+
- `UpperCamelCase` / `lowerCamelCase`。
|
|
22
|
+
- 参数过长换行,类型签名参数与返回值各占一行。
|
|
23
|
+
- 一行只做一件事,避免压缩表达式。
|
|
24
|
+
|
|
25
|
+
## 配置管理
|
|
26
|
+
|
|
27
|
+
- 可修改的运行时配置统一放在 `AppConfig`,避免业务层散落魔法值和可调参数。
|
|
28
|
+
- 默认实现必须只有一个 `AppConfig` 入口,所有配置项、开关、阈值和默认值从这里读取。
|
|
29
|
+
- 配置读取禁止越界分散;新参数先加到 `AppConfig`,再注入到业务逻辑。
|
|
30
|
+
- `AppConfig` 负责默认值、持久化键名、配置来源边界,业务层只读使用。
|
|
31
|
+
|
|
32
|
+
## 注释与控制流
|
|
33
|
+
|
|
34
|
+
- 关键类型、方法、属性必有中文 `///` 注释,写明意图和边界。
|
|
35
|
+
- 系统外边界校验,内部调用信任类型,不加冗余兜底。
|
|
36
|
+
- 统一 `guard` + early return,不要多层 `else`。
|
|
37
|
+
- 禁止 `try?` 吞异常;不写无意义 `value ?? default`。
|
|
38
|
+
|
|
39
|
+
## 全面屏与布局
|
|
40
|
+
|
|
41
|
+
- 默认尊重安全区;固定内容区用 `safeAreaInset` / `safeAreaPadding`。
|
|
42
|
+
- 全屏背景、遮罩、弹层可用 `ignoresSafeArea()`。
|
|
43
|
+
- 禁止使用固定状态栏高度常量。
|
|
44
|
+
|
|
45
|
+
## 动效
|
|
46
|
+
|
|
47
|
+
- 默认优先 `spring`,用同一组参数避免风格撕裂。
|
|
48
|
+
|
|
49
|
+
```swift
|
|
50
|
+
private let motionSpring = Animation.spring(response: 0.5, dampingFraction: 0.75)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 图标与文案
|
|
54
|
+
|
|
55
|
+
- 图标优先 `Image(systemName:)`,无符号则用成熟 SVG。
|
|
56
|
+
- 文案禁止 emoji,禁止硬编码,统一 `loc("key")`。
|
|
57
|
+
- 禁止使用 emoji 作为功能说明、状态、提示。
|
|
58
|
+
|
|
59
|
+
```swift
|
|
60
|
+
Image(systemName: "chevron.right")
|
|
61
|
+
Text(loc("home_title"))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 国际化
|
|
65
|
+
|
|
66
|
+
- 项目必须实现国际化。
|
|
67
|
+
- 默认实现必须包含中文和英文。
|
|
68
|
+
- 使用 `localizationBundle + loc()` + `LocalizationManager` + `AppLanguage` 组合。
|
|
69
|
+
- `loc` 必须走 bundle,不依赖默认 locale 自动解析 key。
|
|
70
|
+
- 语言变更持久化并刷新 `localizationBundle`,`system` 走系统首选。
|
|
71
|
+
|
|
72
|
+
## 资源与时序
|
|
73
|
+
|
|
74
|
+
- 颜色、字号走 token,不用裸值。
|
|
75
|
+
- 时间来源通过注入参数或测试可控入口,不直接散落 `Date()`。
|
|
@@ -40,7 +40,7 @@ skill 与 dwy-shared 仓库深度绑定但**不硬编码路径**。每次执行
|
|
|
40
40
|
CONFIG="$HOME/.config/dwy-shared/repo.json"
|
|
41
41
|
if [ -f "$CONFIG" ]; then
|
|
42
42
|
REPO_PATH=$(jq -r .repo_path "$CONFIG" 2>/dev/null)
|
|
43
|
-
if [ -n "$REPO_PATH" ] && [ -d "$REPO_PATH/.git" ] && [ -d "$REPO_PATH/
|
|
43
|
+
if [ -n "$REPO_PATH" ] && [ -d "$REPO_PATH/.git" ] && [ -d "$REPO_PATH/dwy-cli/templates/ai-tools" ]; then
|
|
44
44
|
echo "OK: $REPO_PATH"
|
|
45
45
|
else
|
|
46
46
|
echo "INVALID"
|
|
@@ -69,7 +69,7 @@ fi
|
|
|
69
69
|
- `test -d "$PATH"` 路径存在
|
|
70
70
|
- `test -d "$PATH/.git"` 是 git 仓库
|
|
71
71
|
- `git -C "$PATH" remote -v | grep -q dwy-shared` remote 指向 dwy-shared
|
|
72
|
-
- `test -d "$PATH/
|
|
72
|
+
- `test -d "$PATH/dwy-cli/templates/ai-tools"` 含特征目录
|
|
73
73
|
4. 全部通过 → 写缓存:
|
|
74
74
|
|
|
75
75
|
```bash
|
|
@@ -114,7 +114,7 @@ fi
|
|
|
114
114
|
### 步骤 3:读现有分类 + 准备推荐上下文
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
TYPE_DIR="$REPO_PATH/
|
|
117
|
+
TYPE_DIR="$REPO_PATH/dwy-cli/templates/ai-tools/<type>"
|
|
118
118
|
for cat in "$TYPE_DIR"/*/; do
|
|
119
119
|
echo "=== $(basename "$cat") ==="
|
|
120
120
|
# 读分类下第一个样例的 description 行(前 100 字)
|
|
@@ -141,7 +141,7 @@ done
|
|
|
141
141
|
### 步骤 6:执行文件操作
|
|
142
142
|
|
|
143
143
|
```bash
|
|
144
|
-
TARGET_DIR="$REPO_PATH/
|
|
144
|
+
TARGET_DIR="$REPO_PATH/dwy-cli/templates/ai-tools/<type>/<category>"
|
|
145
145
|
mkdir -p "$TARGET_DIR"
|
|
146
146
|
|
|
147
147
|
# 源在仓库外:cp
|
|
@@ -183,7 +183,7 @@ description: "调用 dwy create CLI 生成全栈 Vue 3 + FastAPI monorepo 骨架
|
|
|
183
183
|
| 症状 | 原因 | 解决 |
|
|
184
184
|
|------|------|------|
|
|
185
185
|
| `dwy create` 命令找不到 | create-dwy 未安装或不在 PATH | `pnpm i -g create-dwy` 或在 dwy-shared 仓库 `pnpm link --global` |
|
|
186
|
-
| `dwy create` 报模板找不到 |
|
|
186
|
+
| `dwy create` 报模板找不到 | 当前版本 CLI 未包含对应模板,或用户本机 install 的版本落后 | 升级 `create-dwy` 到最新版本;确认 `dwy --version` 与线上版本一致后重试 |
|
|
187
187
|
| `uv sync` 报 dwyeapi 找不到 | aliyun 镜像未同步 | pyproject 已声明 pypi-org 备 index,确保 uv >=0.4 |
|
|
188
188
|
| `pnpm install` 报 catalog 找不到 | pnpm <10 不支持 catalog | 升级: `npm i -g pnpm@latest` |
|
|
189
189
|
| backend health check 失败 | DATABASE_URL/SECRET_KEY 用了占位值导致启动报错 | 检查 .env 是否已 cp 自 .env.example;查 uvicorn stderr 输出 |
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
paths:
|
|
3
|
-
- "**/*.swift"
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Swift 代码规范
|
|
7
|
-
|
|
8
|
-
编辑任何 `.swift` 时遵守。本规则仅覆盖代码写法层(缩进、命名、注释、控制流、UI token 化),是与具体业务无关的通用约定。
|
|
9
|
-
|
|
10
|
-
## 1. 缩进与格式
|
|
11
|
-
|
|
12
|
-
- **4 空格缩进**,不用 Tab。
|
|
13
|
-
- 类型 / 函数声明参数过长时换行,每参数一行,右括号与返回类型 `) -> X` 单起一行。
|
|
14
|
-
- 一行只做一件事,避免把多个 `if` 挤进一行。
|
|
15
|
-
|
|
16
|
-
## 2. 命名
|
|
17
|
-
|
|
18
|
-
- 类型 `UpperCamelCase`,属性 / 方法 / 局部变量 `lowerCamelCase`。
|
|
19
|
-
- **局部短生命周期变量可用短名**(`i` = index、`f` = formatter、`cal` = calendar),同一文件内保持一致。
|
|
20
|
-
- 名字表达意图,避免无信息量的 `data` / `info` / `tmp` 当主力变量名。
|
|
21
|
-
|
|
22
|
-
## 3. 类型声明(iOS 17)
|
|
23
|
-
|
|
24
|
-
状态对象统一 iOS 17 写法,不要 `ObservableObject / @Published / @StateObject`:
|
|
25
|
-
|
|
26
|
-
```swift
|
|
27
|
-
@MainActor // 所有触碰 UI / 主线程隔离状态的类型都标
|
|
28
|
-
@Observable // 替代 ObservableObject
|
|
29
|
-
final class ListViewModel { // 非继承类一律 final
|
|
30
|
-
private let store: DataStore // 依赖私有 + 构造注入
|
|
31
|
-
}
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
## 4. 注释(中文,强制全量)
|
|
35
|
-
|
|
36
|
-
**所有代码都要加注释,包括每个类型和每个方法**。注释说明**意图**(为什么这么做、约束是什么),而非复述代码字面。
|
|
37
|
-
|
|
38
|
-
- **类型顶部必加** `///` 文档注释:说明该类型的职责、它在数据流里的位置或关键约束。`class` / `struct` / `enum` / `protocol` / 有逻辑的 `extension` 都算。
|
|
39
|
-
- **每个方法 / 函数必加** `///` 文档注释(公开和私有都要),说明它做什么、入参出参的语义、可能抛出的错误。
|
|
40
|
-
- 关键属性、反直觉分支、易踩坑处用行内 `//` 标注触发条件。
|
|
41
|
-
- 注释里**禁止 emoji**。
|
|
42
|
-
- 不写废话注释(`// 设置 name` 这种复述赋值的删掉),注释要补充代码没说出口的信息。
|
|
43
|
-
|
|
44
|
-
```swift
|
|
45
|
-
/// 列表数据源。负责从持久层读取并按时间分组,供视图直接渲染。
|
|
46
|
-
@MainActor
|
|
47
|
-
@Observable
|
|
48
|
-
final class ListViewModel {
|
|
49
|
-
|
|
50
|
-
/// 重新加载全部数据并写回 `grouped`。
|
|
51
|
-
/// 失败时透传持久层错误,由调用方决定提示策略。
|
|
52
|
-
func reload() throws {
|
|
53
|
-
let items = try store.all() // 持久层已保证非 nil,无需兜底
|
|
54
|
-
grouped = group(items)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## 5. 控制流:信任上游,不过度防御
|
|
60
|
-
|
|
61
|
-
只在系统外边界(用户输入、文件 / 网络读入、外部 API)校验;内部调用信任类型签名。
|
|
62
|
-
|
|
63
|
-
```swift
|
|
64
|
-
// 好:early return,无多余 else
|
|
65
|
-
guard !isBusy else { return }
|
|
66
|
-
withAnimation(.spring(response: 0.5, dampingFraction: 0.75)) { isBusy = true }
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
- 不写 `value ?? default` 兜已有明确来源的值。
|
|
70
|
-
- 不写 `try? { ... }` 吞异常掩盖 bug;只 catch 预期的具体错误,其余让它抛。
|
|
71
|
-
- 持久 / IO 层方法用 `throws` 透传错误,由调用方决定处理,不在底层吞。
|
|
72
|
-
|
|
73
|
-
## 6. 图标:矢量图,禁止 emoji
|
|
74
|
-
|
|
75
|
-
UI 图标优先使用 **SF Symbols 矢量图**(`Image(systemName:)`)。系统无对应符号时,改用**成熟第三方 SVG 图标**(如已集成并维护良好的 icon 套件),禁止自行手绘或临时 PNG/emoji。
|
|
76
|
-
|
|
77
|
-
```swift
|
|
78
|
-
// 优先:系统 SF Symbols
|
|
79
|
-
Image(systemName: "checkmark")
|
|
80
|
-
.font(.system(size: 11, weight: .bold))
|
|
81
|
-
.foregroundStyle(.white)
|
|
82
|
-
|
|
83
|
-
// 次选:成熟第三方矢量图标组件或资源
|
|
84
|
-
Image("icon_checkmark")
|
|
85
|
-
.resizable()
|
|
86
|
-
.frame(width: 12, height: 12)
|
|
87
|
-
|
|
88
|
-
// 禁止:emoji 当图标
|
|
89
|
-
Text("✅") // 不允许
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
任何视图、文案、注释都**禁止 emoji**。
|
|
93
|
-
|
|
94
|
-
## 7. 颜色 / 字体 / 动效 token 化
|
|
95
|
-
|
|
96
|
-
- 颜色集中到设计 token(如 `ColorTokens.*` / `theme.*`),**禁止在视图里散落硬编码颜色字面量**。
|
|
97
|
-
- 字体用 `.font(.system(size:weight:))` 或集中的字体 token。
|
|
98
|
-
- 动效统一用一组 `.spring(response:dampingFraction:)` 参数,不用 `.easeInOut`,保持全局手感一致。
|
|
99
|
-
|
|
100
|
-
## 8. 文案与时间
|
|
101
|
-
|
|
102
|
-
- 所有 UI 文案走 `String(localized: "some_key")`,**禁止硬编码字面量**;key 命名风格全项目统一。
|
|
103
|
-
- 业务逻辑里「现在」作为依赖注入(`now: @escaping () -> Date = { Date() }`),方便单测固定时间,禁止在逻辑里直接散用 `Date()`。
|