create-dwy 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/cli-help.js +29 -19
- package/src/skill-store.js +4 -4
- package/src/sync-all.js +67 -45
- package/src/sync-codex.js +1 -1
- package/src/sync-cursor.js +2 -1
- package/src/sync-opencode.js +1 -1
- package/src/sync-plan.js +23 -5
- package/src/sync.js +6 -13
- package/src/template-source.js +218 -0
- package/templates/ai-tools/AGENTS.md +4 -4
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-scope-guard/SKILL.md +91 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# create-dwy
|
|
2
2
|
|
|
3
|
+
## [0.25.0] - 2026-11-06
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 同步流程简化为「范围 → Skills → 项目 Rules → 通用规范 → Hooks → CLI → 预览确认」。规范和 Hooks 默认启用;Codex 默认选中,并恢复当前范围已同步的其他 CLI。项目已有有效全局规范或完整 Hooks 时跳过对应选择,不重复安装。
|
|
8
|
+
- 模板从公开源码仓库 master 分支运行时获取,新增 Skills 无需再发布 CLI。使用增量 Git 对象缓存和验证后的模板快照;离线保留有效缓存,无缓存才回退随包模板。首次下载显示进度,异常退出后的锁可自动恢复。
|
|
9
|
+
- 确认后刷新当前范围所需的共享 Skills,项目补其他 CLI 入口时保留有效全局同名共享内容;执行后报告实际完成信息。保留旧脚本的显式同步范围、个人配置及取消前目标不写入的约束。
|
|
10
|
+
- 新增版本克制与想法收录 Skill,支持新增功能分析、阶段收敛、持久化想法清单和新版本规划提醒。
|
|
11
|
+
- 修复无同步记录的旧 Cursor Rules 默认选择恢复,补充全局继承、内容保护、增量更新、离线及多进程锁回归,并更新同步帮助与使用说明。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
3
15
|
## [0.24.1] - 2026-11-03
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/cli-help.js
CHANGED
|
@@ -14,37 +14,39 @@ export function buildHelpText(version) {
|
|
|
14
14
|
`dwy ${version}`,
|
|
15
15
|
'',
|
|
16
16
|
'用法:',
|
|
17
|
-
' dwy
|
|
17
|
+
' dwy 选择范围、配置和 CLI,预览清单后同步',
|
|
18
18
|
' dwy sync 同上',
|
|
19
19
|
' dwy upgrade 升级到 npm 最新正式版,并刷新共享 Skills',
|
|
20
|
-
' dwy refresh
|
|
20
|
+
' dwy refresh 用最新模板刷新已安装的共享 Skills,无需升级 CLI',
|
|
21
21
|
' dwy migrate 一键迁移老项目已有的托管 Skills,原目录先备份',
|
|
22
22
|
' dwy --help 显示本说明',
|
|
23
23
|
' dwy --version 显示版本',
|
|
24
24
|
'',
|
|
25
25
|
'同步步骤:',
|
|
26
26
|
' 1. 选择同步范围:当前项目或全局。',
|
|
27
|
-
' 2.
|
|
28
|
-
' 3.
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
27
|
+
' 2. 选择 Skills。',
|
|
28
|
+
' 3. 仅项目范围选择 Rules;全局跳过。',
|
|
29
|
+
' 4. 选择 AGENTS.md 通用规范,默认启用。',
|
|
30
|
+
' 5. 选择 Hooks,默认启用。',
|
|
31
|
+
' 项目已有有效全局规范或全部所需全局 Hooks 时,跳过对应操作页。',
|
|
32
|
+
' 6. 最后选择 CLI;首次只选 Codex,后续恢复当前范围同步过的 CLI 与有效旧托管入口。',
|
|
33
|
+
' 7. 按最终 CLI 重新核算覆盖,预览实际写入、迁移、备份和清理清单。',
|
|
34
|
+
' 8. 确认后执行,显示实际完成信息;取消不改同步目标。',
|
|
33
35
|
' Commands 不参与同步。',
|
|
34
36
|
'',
|
|
35
37
|
'交互按键:',
|
|
36
38
|
' Skills / Rules:←/→ 切分类,↑/↓ 移动,Space 勾选,a 全选/清空当前分类。',
|
|
37
|
-
' 范围 /
|
|
39
|
+
' 范围 / 通用规范 / Hooks / CLI:↑/↓ 移动;CLI 用 Space 勾选,可输入关键字筛选。',
|
|
38
40
|
' Enter 确认当前步骤,Esc 或 Ctrl+C 取消。',
|
|
39
41
|
'',
|
|
40
42
|
'常见用法:',
|
|
41
|
-
' 同步当前项目:cd your-project 后执行 dwy
|
|
42
|
-
' 同步全局:执行 dwy
|
|
43
|
-
'
|
|
43
|
+
' 同步当前项目:cd your-project 后执行 dwy,选择「当前项目」。',
|
|
44
|
+
' 同步全局:执行 dwy,选择「全局」。',
|
|
45
|
+
' 移除配置:在当前范围取消 Skills / Rules 勾选,或关闭 Hooks / 通用规范,再确认。',
|
|
44
46
|
' 更新或调整配置:再次执行 dwy sync,检查勾选项,再确认同步。',
|
|
45
47
|
' 更新 CLI:dwy upgrade(全局安装时可用;更新后执行 dwy --version 查看版本)。',
|
|
46
48
|
' 迁移老项目:cd your-project 后执行 dwy migrate,无需重新勾选。',
|
|
47
|
-
'
|
|
49
|
+
' 只更新 Skills:执行 dwy refresh,所有已迁移的链接同时生效。',
|
|
48
50
|
' 首次安装:npm install -g create-dwy@latest',
|
|
49
51
|
'',
|
|
50
52
|
'平台支持:',
|
|
@@ -53,24 +55,32 @@ export function buildHelpText(version) {
|
|
|
53
55
|
' OpenCode:全局 ~/.config/opencode/AGENTS.md;项目根 AGENTS.md;当前不支持 Hooks。',
|
|
54
56
|
' Cursor:仅项目 Rules;全局规则在 Cursor 设置中管理。',
|
|
55
57
|
'',
|
|
56
|
-
'
|
|
58
|
+
'同步按当前范围和最终 CLI 执行;项目 AGENTS.md 由多个 CLI 共享。',
|
|
57
59
|
'用户主目录不能作为项目范围同步,请选择全局或进入项目目录。',
|
|
58
|
-
'
|
|
60
|
+
'关闭选项只移除当前范围托管内容;项目操作不移除全局配置。',
|
|
59
61
|
'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
|
|
60
62
|
'项目 Skills 全部适用平台均有全局入口时显示「全局已启用」,不能重复勾选。',
|
|
61
63
|
'部分平台已有全局入口时仍可选,只为缺少全局入口的平台安装项目链接。',
|
|
62
64
|
'Hooks 与 Skills 独立;整组管理,项目仅安装缺少有效全局注册的 Hooks。',
|
|
63
65
|
'全局 Hook 的脚本与对应事件、matcher、完整命令注册均有效时,项目禁止重复安装。',
|
|
64
|
-
'
|
|
66
|
+
'任一适用 CLI 已有 dwy 全局通用规范时,项目继承并清理重复基线;最终 CLI 中缺失的全局规范需补齐。',
|
|
65
67
|
'手写 AGENTS.md 本身不算全局已安装;通用规范与 Rules 共用文件但独立管理。',
|
|
66
|
-
'
|
|
68
|
+
'移除通用规范仅删除基线块;项目 Rules 独立管理,保留手写内容。',
|
|
67
69
|
'全局启用会明确列出当前项目重复项清理;全局移除不会自动向项目补装。',
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
+
'项目同步只清理项目重复入口,不改写有效全局提供者;真实目录先备份。',
|
|
71
|
+
'确认前仅可更新模板缓存;确认后才改项目/全局配置,并刷新所选共享 Skills。',
|
|
72
|
+
'由有效全局入口提供的 Skills 不借项目同步改写;全部已安装副本用 dwy refresh 更新。',
|
|
73
|
+
'sync / migrate 脚本接口仍须显式指定范围。',
|
|
70
74
|
'产品流程缺失的外部依赖也列入清单,确认后联网补齐,已有内容保留。',
|
|
71
75
|
'旧真实目录备份到同步根的 .dwy/skill-backups;个人 Skills、Rules、Hooks 不参与 migrate。',
|
|
72
76
|
'旧项目首次需执行 dwy migrate;迁移后无需逐项目刷新 Skills。',
|
|
73
77
|
'Rules / Hooks 的模板更新仍需执行 dwy sync;已有会话缓存的 Skill 可能需要重新加载。',
|
|
74
78
|
'',
|
|
79
|
+
'模板更新:',
|
|
80
|
+
' 固定公开仓库 https://github.com/danweiyuancircle/dwy-ai-experience.git 的 master 分支 dwy-cli/templates/ai-tools。',
|
|
81
|
+
' DWY_TEMPLATE_SOURCE_DIR 开发覆盖优先;远端模板缓存在 ~/.dwy/template-source。',
|
|
82
|
+
' 未变更只检查 commit;离线保留有效缓存,无有效缓存时回退包内模板并提示。',
|
|
83
|
+
' 新增 Skills 只需更新模板分发分支,无需发布 CLI;dwy upgrade 更新程序。',
|
|
84
|
+
'',
|
|
75
85
|
].join('\n')
|
|
76
86
|
}
|
package/src/skill-store.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function ensureSharedSkills({ skills, homeDir = os.homedir() }) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
39
|
+
* 将当前模板 Skills 准备到稳定用户目录,返回指向共享副本的条目。
|
|
40
40
|
* 同一用户的刷新互斥;完整副本准备失败时不替换旧内容。外部 Skills 缓存不纳入本模块清单。
|
|
41
41
|
*/
|
|
42
42
|
export async function prepareSharedSkills({ skills, homeDir = os.homedir() }) {
|
|
@@ -183,15 +183,15 @@ export async function linkSkill({ skill, dest, backupRoot }) {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/**
|
|
186
|
+
/** 从最新可用模板刷新已有共享副本;模板移除的条目保留,避免其他项目坏链。 */
|
|
187
187
|
export async function refreshSharedSkills({ sourceDir, homeDir = os.homedir() } = {}) {
|
|
188
188
|
const names = await readInstalledNames(homeDir)
|
|
189
|
-
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
189
|
+
const scans = await scanSkills(sourceDir || await resolveSourceDir({ homeDir }))
|
|
190
190
|
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
191
191
|
const updated = names.filter(name => available.has(name))
|
|
192
192
|
const retained = names.filter(name => !available.has(name))
|
|
193
193
|
await prepareSharedSkills({ skills: updated.map(name => available.get(name)), homeDir })
|
|
194
|
-
for (const name of retained) console.log(chalk.yellow(
|
|
194
|
+
for (const name of retained) console.log(chalk.yellow(`当前模板已移除 ${name},保留共享旧副本以免其他项目链接失效。`))
|
|
195
195
|
console.log(chalk.green(`已刷新 ${updated.length} 个共享 Skills,保留 ${retained.length} 个旧副本。`))
|
|
196
196
|
return { updated, retained }
|
|
197
197
|
}
|
package/src/sync-all.js
CHANGED
|
@@ -7,12 +7,12 @@ import { normalizeSkillScope, resolveProjectSkillDir, resolveGlobalSkillDir, cop
|
|
|
7
7
|
import { ensureSharedSkills } from './skill-store.js'
|
|
8
8
|
import { chalk } from './utils.js'
|
|
9
9
|
import { interactiveSelect, loadHookManifests, readBaselineDoc, resolveSourceDir, scanExisting, scanHooks, scanRules, scanSkills } from './sync.js'
|
|
10
|
-
import { parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
|
|
10
|
+
import { hasManagedBaseline, parseManagedRuleNames, scanExistingCodexHooks, scanExistingCodexSkills } from './sync-codex.js'
|
|
11
11
|
import { scanExistingCursorRules } from './sync-cursor.js'
|
|
12
12
|
import { installSkills } from './skills-install.js'
|
|
13
13
|
import { selfUpgrade } from './self-upgrade.js'
|
|
14
14
|
import { buildSyncPlan, contentOptions, projectSkillChoices, PLATFORM_LABELS, PLATFORM_SYNC_TYPES } from './sync-plan.js'
|
|
15
|
-
import { assertConfigPath, baselineProviders, globalHookNames } from './sync-inheritance.js'
|
|
15
|
+
import { assertConfigPath, baselineProviders, globalBaselinePath, globalHookNames, hasManagedHookConfig } from './sync-inheritance.js'
|
|
16
16
|
|
|
17
17
|
const PLATFORM_OPTIONS = [
|
|
18
18
|
{ value: 'claude', label: 'Claude Code', description: '支持 Skills、项目 Rules、Hooks 和全局通用规范' },
|
|
@@ -59,25 +59,44 @@ export function buildSelectionDefaultsFromSyncState(existing, syncState, platfor
|
|
|
59
59
|
return Object.values(result).some(names => names.size) ? result : existing
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/** Codex 默认选中;恢复当前范围的同步记录和有效旧托管入口,空目录不算已同步。 */
|
|
63
63
|
export async function buildPlatformDefaultsFromLocalState(projectDir, scope = 'project', codexHome = path.join(projectDir, '.codex')) {
|
|
64
|
-
const defaults = []
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
const defaults = ['codex']
|
|
65
|
+
const state = await loadSyncState(projectDir)
|
|
66
|
+
for (const platform of ['claude', 'opencode', ...(scope === 'project' ? ['cursor'] : [])]) {
|
|
67
|
+
const root = scope === 'global' ? resolveGlobalSkillDir(platform, projectDir)
|
|
68
|
+
: resolveProjectSkillDir(projectDir, platform)
|
|
69
|
+
let managed = Boolean(state.platforms[platform])
|
|
70
|
+
if (root && await fs.pathExists(root)) {
|
|
71
|
+
for (const name of await fs.readdir(root)) {
|
|
72
|
+
if (name.startsWith('dwy-') && await fs.pathExists(path.join(root, name, 'SKILL.md'))) managed = true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (platform === 'cursor') {
|
|
76
|
+
if ([...await scanExistingCursorRules(projectDir)].some(name => name.startsWith('dwy-') || name === '00-dwy-global.mdc')) managed = true
|
|
77
|
+
} else {
|
|
78
|
+
const baseline = scope === 'global' ? globalBaselinePath(platform, projectDir, codexHome)
|
|
79
|
+
: path.join(projectDir, platform === 'claude' ? '.claude' : '.opencode', 'AGENTS.md')
|
|
80
|
+
if (await hasManagedBaseline(baseline)) managed = true
|
|
81
|
+
if (platform === 'claude') {
|
|
82
|
+
const configRoot = path.join(projectDir, '.claude')
|
|
83
|
+
if (await hasManagedBaseline(path.join(configRoot, 'CLAUDE.md'))
|
|
84
|
+
|| await hasManagedHookConfig(platform, configRoot, await scanExisting(configRoot, 'hooks'))) managed = true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (managed) defaults.push(platform)
|
|
88
|
+
}
|
|
89
|
+
return defaults
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
async function selectPlatforms(root, scope,
|
|
92
|
+
async function selectPlatforms(root, scope, initialValues) {
|
|
93
|
+
const state = await loadSyncState(root)
|
|
76
94
|
const result = await searchableMultiselect({
|
|
77
95
|
message: '选择要同步的平台',
|
|
78
96
|
options: PLATFORM_OPTIONS.filter(option => scope === 'project' || option.value !== 'cursor')
|
|
79
|
-
.map(option => ({ ...option, description:
|
|
80
|
-
|
|
97
|
+
.map(option => ({ ...option, description: [state.platforms[option.value] ? '已有同步记录,将核对并修复本地配置' : '',
|
|
98
|
+
contentOptions(scope, [option.value]).map(item => item.label).join('、')].filter(Boolean).join(';') })),
|
|
99
|
+
initialValues,
|
|
81
100
|
required: true, maxItems: 6, placeholder: SEARCH_PLACEHOLDER,
|
|
82
101
|
})
|
|
83
102
|
return isCancel(result) ? null : result
|
|
@@ -135,20 +154,12 @@ function staleEntries(scans, state, existing, platforms) {
|
|
|
135
154
|
return result
|
|
136
155
|
}
|
|
137
156
|
|
|
138
|
-
/** 全局首次默认管理基线;任一所选平台显式移除后不再整组默认开启。 */
|
|
139
|
-
export function defaultContentTypes(scope, platforms, state) {
|
|
140
|
-
const allowed = new Set(contentOptions(scope, platforms).map(option => option.value))
|
|
141
|
-
const result = allowed.has('skills') ? ['skills'] : []
|
|
142
|
-
if (scope === 'global' && allowed.has('baseline') && platforms.every(platform => state.platforms[platform]?.baseline !== false)) result.push('baseline')
|
|
143
|
-
return result
|
|
144
|
-
}
|
|
145
|
-
|
|
146
157
|
function normalizeRemovals(raw) {
|
|
147
158
|
return Object.fromEntries(Object.entries(raw).map(([platform, types]) => [platform,
|
|
148
159
|
Object.fromEntries(Object.entries(types).map(([type, names]) => [type, new Set(names)]))]))
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
/** `dwy` 和 `dwy sync` 共用范围 →
|
|
162
|
+
/** `dwy` 和 `dwy sync` 共用范围 → Skills → 项目 Rules → 规范 → Hooks → CLI → 确认。 */
|
|
152
163
|
export async function runDwy(opts = {}) {
|
|
153
164
|
console.log(chalk.bold.cyan('\ndwy') + chalk.gray(' · AI 开发工具\n'))
|
|
154
165
|
const action = normalizeAction(opts.action)
|
|
@@ -174,7 +185,7 @@ export async function migrateSkills({
|
|
|
174
185
|
if (await fs.pathExists(homeDir) && realProject === await fs.realpath(homeDir)) {
|
|
175
186
|
throw new Error('当前目录是用户主目录,请使用全局同步或进入项目目录迁移')
|
|
176
187
|
}
|
|
177
|
-
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
188
|
+
const scans = await scanSkills(sourceDir || await resolveSourceDir({ homeDir }))
|
|
178
189
|
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
179
190
|
const state = await loadSyncState(projectDir)
|
|
180
191
|
const existingByPlatform = {}
|
|
@@ -222,7 +233,7 @@ export async function migrateSkills({
|
|
|
222
233
|
|
|
223
234
|
/**
|
|
224
235
|
* 只执行点名范围和内容;测试/脚本可注入选择,确认前只读。
|
|
225
|
-
*
|
|
236
|
+
* 主交互按内容顺序收集选择,CLI 后选,执行前重新核算继承;显式脚本仅处理点名的类型。
|
|
226
237
|
*/
|
|
227
238
|
export async function syncAll({
|
|
228
239
|
sourceDir: sourceDirOverride, projectDir: projectDirOverride, selected: selectedOverride,
|
|
@@ -231,7 +242,7 @@ export async function syncAll({
|
|
|
231
242
|
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'), scope: scopeOverride, confirmed,
|
|
232
243
|
contentActions: actionsOverride,
|
|
233
244
|
} = {}) {
|
|
234
|
-
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
245
|
+
const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
|
|
235
246
|
if (!await fs.pathExists(sourceDir)) throw new Error('ai-tools templates not found in repo')
|
|
236
247
|
const projectDir = projectDirOverride || process.cwd()
|
|
237
248
|
const scope = scopeOverride || (selectedOverride || platformsOverride ? 'project' : await searchableSelect({
|
|
@@ -244,33 +255,32 @@ export async function syncAll({
|
|
|
244
255
|
if (isCancel(scope)) { console.log(chalk.yellow('已取消同步。')); return }
|
|
245
256
|
if (!['project', 'global'].includes(scope)) throw new Error('无效同步范围')
|
|
246
257
|
const global = scope === 'global', targetRoot = global ? homeDir : projectDir
|
|
247
|
-
|
|
248
|
-
if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
|
|
258
|
+
let platforms = platformsOverride || await buildPlatformDefaultsFromLocalState(targetRoot, scope, codexHome)
|
|
249
259
|
if (platforms.length === 0 || platforms.some(platform => !PLATFORM_SYNC_TYPES[platform] || (global && platform === 'cursor'))) throw new Error('所选平台不支持此同步范围')
|
|
250
|
-
const options = contentOptions(scope, platforms)
|
|
251
|
-
const allowed = new Set(options.map(option => option.value))
|
|
252
260
|
const projectState = await loadSyncState(projectDir), globalState = await loadSyncState(homeDir)
|
|
253
261
|
const targetState = global ? globalState : projectState
|
|
262
|
+
const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
|
|
263
|
+
const mainFlow = !typesOverride && !selectedOverride && modeOverride !== SYNC_MODE_SKILLS
|
|
264
|
+
const availablePlatforms = PLATFORM_OPTIONS.filter(option => !global || option.value !== 'cursor').map(option => option.value)
|
|
265
|
+
const allowed = new Set(contentOptions(scope, mainFlow ? availablePlatforms : platforms).map(option => option.value))
|
|
254
266
|
let contentTypes = typesOverride
|
|
255
267
|
if (!contentTypes && modeOverride === SYNC_MODE_SKILLS) contentTypes = ['skills']
|
|
256
268
|
if (!contentTypes && selectedOverride) contentTypes = Object.keys(selectedOverride).filter(type => allowed.has(type))
|
|
257
269
|
if (!contentTypes) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
})
|
|
262
|
-
if (isCancel(result)) { console.log(chalk.yellow('已取消同步。')); return }
|
|
263
|
-
contentTypes = result
|
|
270
|
+
contentTypes = [...allowed]
|
|
271
|
+
if (!await fs.pathExists(path.join(sourceDir, 'AGENTS.md'))) contentTypes = contentTypes.filter(type => type !== 'baseline')
|
|
272
|
+
if (!scans.hooks.length) contentTypes = contentTypes.filter(type => type !== 'hooks')
|
|
264
273
|
}
|
|
265
274
|
if (contentTypes.some(type => !allowed.has(type))) throw new Error('所选范围或平台不支持此同步内容')
|
|
266
275
|
if (contentTypes.length === 0) { console.log('未选择处理内容。'); return }
|
|
267
|
-
const scans = { skills: await scanSkills(sourceDir), rules: await scanRules(sourceDir), hooks: await scanHooks(sourceDir), commands: [] }
|
|
268
276
|
const existing = await managedNames(targetRoot, global, codexHome)
|
|
277
|
+
const cursorRules = new Set(scans.rules.filter(rule => existing.cursor.rules.has(buildCursorRuleName(rule.name))).map(rule => rule.name))
|
|
269
278
|
const current = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type,
|
|
270
|
-
unionSets(...platforms.map(platform => existing[platform][type]))]))
|
|
279
|
+
unionSets(...platforms.map(platform => platform === 'cursor' && type === 'rules' ? cursorRules : existing[platform][type]))]))
|
|
271
280
|
const defaults = buildSelectionDefaultsFromSyncState(current, targetState, platforms)
|
|
272
281
|
for (const type of ['skills', 'rules', 'hooks']) defaults[type] = unionSets(defaults[type], current[type])
|
|
273
|
-
|
|
282
|
+
// CLI 尚未选择时允许勾选所有 Skills;最终计划按实际 CLI 去重,避免提前禁用其他 CLI 所需条目。
|
|
283
|
+
const choices = platformsOverride && !global && contentTypes.includes('skills')
|
|
274
284
|
? { ...scans, skills: await projectSkillChoices(scans.skills, platforms, { homeDir, codexHome }) } : scans
|
|
275
285
|
const selected = selectedOverride
|
|
276
286
|
? { skills: selectedOverride.skills || [], rules: selectedOverride.rules || [], hooks: [], commands: [] }
|
|
@@ -278,11 +288,10 @@ export async function syncAll({
|
|
|
278
288
|
if (selected === null) { console.log(chalk.yellow('已取消同步。')); return }
|
|
279
289
|
const contentActions = { ...actionsOverride }
|
|
280
290
|
if (!selectedOverride) {
|
|
281
|
-
for (const type of ['
|
|
291
|
+
for (const type of ['baseline', 'hooks'].filter(type => contentTypes.includes(type))) {
|
|
282
292
|
if (contentActions[type]) continue
|
|
283
293
|
const inherited = type === 'baseline' && !global ? await baselineProviders(platforms, { homeDir, codexHome }) : []
|
|
284
294
|
const name = type === 'hooks' ? 'Hooks' : 'AGENTS.md 通用规范'
|
|
285
|
-
if (inherited.length) console.log(chalk.yellow(`全局已启用通用规范:${inherited.map(p => PLATFORM_LABELS[p]).join('、')};项目禁止重复安装,其他平台需补齐全局规范。`))
|
|
286
295
|
let inheritedHooks = false
|
|
287
296
|
if (type === 'hooks' && !global) {
|
|
288
297
|
const manifests = await loadHookManifests(sourceDir)
|
|
@@ -292,14 +301,17 @@ export async function syncAll({
|
|
|
292
301
|
const provided = await globalHookNames({ platform, sourceDir, homeDir, codexHome })
|
|
293
302
|
total += available.length
|
|
294
303
|
covered += available.filter(hook => provided.has(hook.name)).length
|
|
295
|
-
if (provided.size) console.log(`${PLATFORM_LABELS[platform]} Hooks 全局已启用:${[...provided].join('、')}`)
|
|
296
304
|
}
|
|
297
305
|
inheritedHooks = total > 0 && covered === total
|
|
298
306
|
}
|
|
307
|
+
if (inherited.length || inheritedHooks) {
|
|
308
|
+
contentActions[type] = 'sync'
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
299
311
|
const action = await searchableSelect({
|
|
300
312
|
message: `选择 ${name} 操作`, initialValue: 'sync',
|
|
301
313
|
options: [
|
|
302
|
-
{ value: 'sync', label:
|
|
314
|
+
{ value: 'sync', label: '启用 / 更新',
|
|
303
315
|
description: type === 'hooks' && !global ? '只为缺少有效全局注册的 Hook 安装项目配置,其他仅去重' : '执行清单会列出实际路径及清理项' },
|
|
304
316
|
{ value: 'remove', label: `移除当前范围的${name}`, description: '只移除 dwy 托管内容,保留个人配置和其他类型' },
|
|
305
317
|
],
|
|
@@ -308,6 +320,12 @@ export async function syncAll({
|
|
|
308
320
|
contentActions[type] = action
|
|
309
321
|
}
|
|
310
322
|
}
|
|
323
|
+
if (!platformsOverride) {
|
|
324
|
+
platforms = await selectPlatforms(targetRoot, scope, platforms)
|
|
325
|
+
if (platforms === null) { console.log(chalk.yellow('已取消同步。')); return }
|
|
326
|
+
}
|
|
327
|
+
const options = contentOptions(scope, platforms)
|
|
328
|
+
if (mainFlow) contentTypes = contentTypes.filter(type => options.some(option => option.value === type))
|
|
311
329
|
const stale = staleEntries(scans, targetState, existing, platforms)
|
|
312
330
|
const removals = normalizeRemovals(removalsOverride || stale)
|
|
313
331
|
const baseline = contentTypes.some(type => type === 'rules' || type === 'baseline') ? await readBaselineDoc(sourceDir) : ''
|
|
@@ -315,6 +333,7 @@ export async function syncAll({
|
|
|
315
333
|
scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
|
|
316
334
|
projectState, globalState, staleRemovals: removals, staleEntries: stale, baseline,
|
|
317
335
|
contentActions,
|
|
336
|
+
refreshSkills: mainFlow,
|
|
318
337
|
skillScope: normalizeSkillScope(skillScopeOverride),
|
|
319
338
|
})
|
|
320
339
|
const nextTarget = global ? plan.nextGlobal : plan.nextProject
|
|
@@ -345,7 +364,10 @@ export async function syncAll({
|
|
|
345
364
|
const answer = confirmed === false ? false : await confirm({ message: '确认同步?' })
|
|
346
365
|
if (isCancel(answer) || !answer) { console.log(chalk.yellow('已取消同步。')); return }
|
|
347
366
|
}
|
|
348
|
-
for (const operation of plan.operations)
|
|
367
|
+
for (const operation of plan.operations) {
|
|
368
|
+
await operation.run()
|
|
369
|
+
if (!['state', 'inherit'].includes(operation.kind)) console.log(chalk.green(`已完成:${operation.label}`))
|
|
370
|
+
}
|
|
349
371
|
console.log(chalk.green('同步完成。'))
|
|
350
372
|
return plan.operations.map(({ kind, label, paths }) => ({ kind, label, paths }))
|
|
351
373
|
}
|
package/src/sync-codex.js
CHANGED
|
@@ -377,7 +377,7 @@ export async function syncCodex({
|
|
|
377
377
|
syncTypes = ['skills', 'hooks'],
|
|
378
378
|
staleRemovals = {},
|
|
379
379
|
} = {}) {
|
|
380
|
-
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
380
|
+
const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
|
|
381
381
|
if (!await fs.pathExists(sourceDir)) {
|
|
382
382
|
throw new Error('ai-tools templates not found in repo')
|
|
383
383
|
}
|
package/src/sync-cursor.js
CHANGED
|
@@ -108,10 +108,11 @@ export async function scanExistingCursorRules(projectDir) {
|
|
|
108
108
|
export async function syncCursor({
|
|
109
109
|
sourceDir: sourceDirOverride,
|
|
110
110
|
projectDir,
|
|
111
|
+
homeDir,
|
|
111
112
|
selected,
|
|
112
113
|
staleRemovals = {},
|
|
113
114
|
} = {}) {
|
|
114
|
-
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
115
|
+
const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
|
|
115
116
|
if (!await fs.pathExists(sourceDir)) {
|
|
116
117
|
throw new Error('ai-tools templates not found in repo')
|
|
117
118
|
}
|
package/src/sync-opencode.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function syncOpenCode({
|
|
|
48
48
|
sharedSkills,
|
|
49
49
|
staleRemovals = {},
|
|
50
50
|
} = {}) {
|
|
51
|
-
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
51
|
+
const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
|
|
52
52
|
if (!await fs.pathExists(sourceDir)) {
|
|
53
53
|
throw new Error('ai-tools templates not found in repo')
|
|
54
54
|
}
|
package/src/sync-plan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { ensureSharedSkills, linkSkill, removeSkillEntry } from './skill-store.js'
|
|
3
|
+
import { ensureSharedSkills, prepareSharedSkills, linkSkill, removeSkillEntry } from './skill-store.js'
|
|
4
4
|
import { findGlobalSkill, globalSkillRoots, resolveGlobalSkillDir, resolveProjectSkillDir } from './sync-global-skills.js'
|
|
5
5
|
import { loadHookManifests, syncClaude } from './sync.js'
|
|
6
6
|
import { hasManagedBaseline, removeLegacyBaseline, syncBaselineDoc, syncCodex, syncProjectAgentsMd } from './sync-codex.js'
|
|
@@ -69,12 +69,13 @@ function ownedGlobalRoots(platform, homeDir, codexHome) {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* 只读计算本次全部操作和下一份状态;run 回调只在确认后执行。
|
|
72
|
-
*
|
|
72
|
+
* 主交互可在确认后刷新所选共享内容;显式脚本默认仅补缺,项目去重不改写全局提供者。
|
|
73
73
|
*/
|
|
74
74
|
export async function buildSyncPlan({
|
|
75
75
|
scope, platforms, contentTypes, selected, scans, sourceDir, projectDir, homeDir, codexHome,
|
|
76
76
|
projectState, globalState, staleRemovals = {}, staleEntries = {}, baseline,
|
|
77
77
|
contentActions = {},
|
|
78
|
+
refreshSkills = false,
|
|
78
79
|
skillScope = { destinations: ['project'], globalSkills: [] },
|
|
79
80
|
}) {
|
|
80
81
|
const global = scope === 'global'
|
|
@@ -196,9 +197,25 @@ export async function buildSyncPlan({
|
|
|
196
197
|
}
|
|
197
198
|
if (neededSkills.size) {
|
|
198
199
|
const skills = [...neededSkills.values()]
|
|
199
|
-
|
|
200
|
+
const preserved = []
|
|
201
|
+
if (refreshSkills && !global) {
|
|
202
|
+
for (let i = 0; i < skills.length; i++) {
|
|
203
|
+
const skill = skills[i], shared = path.join(homeDir, '.dwy', 'skills', skill.name)
|
|
204
|
+
if (!await validSkill(shared)) continue
|
|
205
|
+
for (const platform of ['claude', 'codex', 'opencode']) {
|
|
206
|
+
if (!await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) continue
|
|
207
|
+
// 补项目入口不能间接刷新其他 CLI 正在使用的全局共享副本。
|
|
208
|
+
skills[i] = { ...skill, sourcePath: shared }
|
|
209
|
+
preserved.push(skill.name)
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const label = refreshSkills ? `更新所选共享 Skills(所有同名链接生效)${preserved.length ? `;保留全局共享内容:${preserved.join('、')}` : ''}`
|
|
215
|
+
: '准备缺失的共享副本;已有内容不刷新'
|
|
216
|
+
operations.unshift({ kind: 'ensure-skills', label,
|
|
200
217
|
paths: [...skills.map(skill => path.join(homeDir, '.dwy', 'skills', skill.name)), path.join(homeDir, '.dwy', 'skill-store.json')],
|
|
201
|
-
run: () => ensureSharedSkills({ skills, homeDir }) })
|
|
218
|
+
run: () => (refreshSkills ? prepareSharedSkills : ensureSharedSkills)({ skills, homeDir }) })
|
|
202
219
|
}
|
|
203
220
|
if ([...neededSkills.values()].some(skill => skill.category === '产品0到1')) {
|
|
204
221
|
const target = path.join(process.env.DWY_HOME || path.join(homeDir, '.dwy'), 'skills')
|
|
@@ -272,7 +289,8 @@ export async function buildSyncPlan({
|
|
|
272
289
|
const dest = path.join(projectDir, 'AGENTS.md')
|
|
273
290
|
if (remove || inherited) {
|
|
274
291
|
await cleanBaseline(dest, projectDir, inherited ? '全局已启用,清理项目重复基线' : '移除项目通用规范(保留 Rules 和手写内容)')
|
|
275
|
-
|
|
292
|
+
const missing = cliPlatforms.filter(platform => !providers.includes(platform))
|
|
293
|
+
if (inherited) add('inherit', `通用规范使用全局:${providers.map(p => PLATFORM_LABELS[p]).join('、')};项目禁止重复安装${missing.length ? `;${missing.map(p => PLATFORM_LABELS[p]).join('、')} 缺少全局规范,请另行选择全局范围补齐` : ''}`,
|
|
276
294
|
providers.map(p => globalBaselinePath(p, homeDir, codexHome)), async () => {})
|
|
277
295
|
} else await addConfig('baseline', '启用/更新项目 AGENTS.md 通用规范(保留 Rules 和手写内容)', [dest], projectDir, () => syncBaselineDoc(baseline, dest))
|
|
278
296
|
markProject(!remove && !inherited)
|
package/src/sync.js
CHANGED
|
@@ -2,8 +2,9 @@ import fs from 'fs-extra'
|
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { isCancel } from '@clack/prompts'
|
|
4
4
|
import { tabMultiselect } from './tab-multiselect.js'
|
|
5
|
-
import { chalk
|
|
5
|
+
import { chalk } from './utils.js'
|
|
6
6
|
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
7
|
+
import { resolveTemplateSource } from './template-source.js'
|
|
7
8
|
|
|
8
9
|
const CATEGORIES = [
|
|
9
10
|
{ key: 'skills', label: 'Skills' },
|
|
@@ -390,17 +391,9 @@ async function syncSettings(sourceDir, targetDir, selectedHookNames, global, sta
|
|
|
390
391
|
return 1
|
|
391
392
|
}
|
|
392
393
|
|
|
393
|
-
/**
|
|
394
|
-
export async function resolveSourceDir() {
|
|
395
|
-
|
|
396
|
-
if (explicitSourceDir) {
|
|
397
|
-
if (!await fs.pathExists(explicitSourceDir)) {
|
|
398
|
-
throw new Error(`DWY_TEMPLATE_SOURCE_DIR not found: ${explicitSourceDir}`)
|
|
399
|
-
}
|
|
400
|
-
return explicitSourceDir
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return path.join(PACKAGE_ROOT, 'templates', 'ai-tools')
|
|
394
|
+
/** 解析模板源并检查公开模板更新;调用方可注入 homeDir 隔离用户缓存。 */
|
|
395
|
+
export async function resolveSourceDir(options) {
|
|
396
|
+
return resolveTemplateSource(options)
|
|
404
397
|
}
|
|
405
398
|
|
|
406
399
|
/** 同步 Claude Skills 与 Hooks;基线和项目 Rules 由总同步入口分别处理。 */
|
|
@@ -414,7 +407,7 @@ export async function syncClaude({
|
|
|
414
407
|
syncTypes = ['skills', 'rules', 'hooks'],
|
|
415
408
|
staleRemovals = {},
|
|
416
409
|
} = {}) {
|
|
417
|
-
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
410
|
+
const sourceDir = sourceDirOverride || await resolveSourceDir({ homeDir })
|
|
418
411
|
if (!await fs.pathExists(sourceDir)) {
|
|
419
412
|
throw new Error('ai-tools templates not found in repo')
|
|
420
413
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { spinner } from '@clack/prompts'
|
|
8
|
+
import { PACKAGE_ROOT } from './utils.js'
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile)
|
|
11
|
+
const REMOTE = 'https://github.com/danweiyuancircle/dwy-ai-experience.git'
|
|
12
|
+
const TEMPLATE_PATH = ['dwy-cli', 'templates', 'ai-tools']
|
|
13
|
+
const REVISION_FILE = '.dwy-source-revision'
|
|
14
|
+
|
|
15
|
+
async function statEntry(file) {
|
|
16
|
+
return fs.lstat(file).catch(error => {
|
|
17
|
+
if (error.code === 'ENOENT') return null
|
|
18
|
+
throw error
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function requireDirectory(directory) {
|
|
23
|
+
const stat = await fs.lstat(directory)
|
|
24
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`模板目录不能是软链接或文件:${directory}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function validateTemplates(directory) {
|
|
28
|
+
await requireDirectory(directory)
|
|
29
|
+
await validateTree(directory)
|
|
30
|
+
for (const name of ['skills', 'rules', 'hooks']) await requireDirectory(path.join(directory, name))
|
|
31
|
+
const baseline = path.join(directory, 'AGENTS.md')
|
|
32
|
+
if (!(await fs.lstat(baseline)).isFile() || !(await fs.readFile(baseline, 'utf8')).trim()) {
|
|
33
|
+
throw new Error('模板缺少有效 AGENTS.md')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function validateTree(directory) {
|
|
38
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
39
|
+
if (entry.isSymbolicLink()) throw new Error(`模板不能包含软链接:${entry.name}`)
|
|
40
|
+
if (entry.isDirectory()) await validateTree(path.join(directory, entry.name))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function deadLockExists(lockPath) {
|
|
45
|
+
const stat = await statEntry(lockPath)
|
|
46
|
+
if (!stat) return false
|
|
47
|
+
if (!stat.isFile()) throw new Error(`模板锁无效,保留原文件:${lockPath}`)
|
|
48
|
+
const owner = await fs.readFile(lockPath, 'utf8').catch(error => {
|
|
49
|
+
if (error.code === 'ENOENT') return null
|
|
50
|
+
throw error
|
|
51
|
+
})
|
|
52
|
+
if (owner === null) return false
|
|
53
|
+
const pid = Number(owner.trim())
|
|
54
|
+
if (!/^[1-9]\d*\s*$/.test(owner) || !Number.isSafeInteger(pid) || pid > 2147483647) {
|
|
55
|
+
throw new Error(`模板锁无效,保留原文件:${lockPath}`)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code === 'ESRCH') return true
|
|
61
|
+
if (error.code !== 'EPERM') throw error
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`进程 ${pid} 正在更新模板,保留现有锁。`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function acquireLock(lockPath, candidate) {
|
|
67
|
+
while (true) {
|
|
68
|
+
try {
|
|
69
|
+
await fs.link(candidate, lockPath)
|
|
70
|
+
return
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code !== 'EEXIST') throw error
|
|
73
|
+
}
|
|
74
|
+
if (!await deadLockExists(lockPath)) continue
|
|
75
|
+
// 回收也须互斥,避免两个进程删掉对方的新锁;回收中断时按同一协议恢复。
|
|
76
|
+
const recovery = `${lockPath}.recovery`
|
|
77
|
+
await acquireLock(recovery, candidate)
|
|
78
|
+
try {
|
|
79
|
+
if (await deadLockExists(lockPath)) await fs.unlink(lockPath)
|
|
80
|
+
try {
|
|
81
|
+
await fs.link(candidate, lockPath)
|
|
82
|
+
return
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== 'EEXIST') throw error
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
await fs.unlink(recovery)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function git(args, cwd) {
|
|
93
|
+
try {
|
|
94
|
+
const { stdout } = await execFileAsync('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
|
|
95
|
+
cwd,
|
|
96
|
+
// 不加载用户安装的 hooks 或过滤器,避免检出远端内容时执行脚本。
|
|
97
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
|
|
98
|
+
// 首次检出要获取整套模板,不能沿用轻量版本检查的短超时。
|
|
99
|
+
timeout: ['clone', 'checkout', 'fetch', 'reset'].includes(args[0]) ? 300000 : 15000,
|
|
100
|
+
maxBuffer: 1024 * 1024,
|
|
101
|
+
})
|
|
102
|
+
return stdout.trim()
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`Git 获取模板失败(${error.code || error.signal})`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function refreshTemplates(root, cache) {
|
|
109
|
+
await fs.ensureDir(root)
|
|
110
|
+
await requireDirectory(root)
|
|
111
|
+
const lockPath = path.join(root, 'template-source.lock')
|
|
112
|
+
const candidate = path.join(root, `.template-source-lock-${randomUUID()}`)
|
|
113
|
+
await fs.writeFile(candidate, `${process.pid}\n`, { flag: 'wx' })
|
|
114
|
+
try {
|
|
115
|
+
await acquireLock(lockPath, candidate)
|
|
116
|
+
} finally {
|
|
117
|
+
await fs.unlink(candidate)
|
|
118
|
+
}
|
|
119
|
+
let staging
|
|
120
|
+
const previous = path.join(root, '.template-source.previous')
|
|
121
|
+
try {
|
|
122
|
+
if (await statEntry(cache)) await requireDirectory(cache)
|
|
123
|
+
if (await statEntry(previous)) {
|
|
124
|
+
await validateTemplates(previous)
|
|
125
|
+
if (await statEntry(cache)) {
|
|
126
|
+
await validateTemplates(cache)
|
|
127
|
+
await fs.remove(previous)
|
|
128
|
+
} else {
|
|
129
|
+
await fs.rename(previous, cache)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const revision = (await git(['ls-remote', '--exit-code', REMOTE, 'refs/heads/master'], root)).split(/\s/)[0]
|
|
133
|
+
if (!/^[a-f\d]{40,64}$/.test(revision)) throw new Error('远端 master 版本无效')
|
|
134
|
+
if (await statEntry(cache)) {
|
|
135
|
+
try {
|
|
136
|
+
await validateTemplates(cache)
|
|
137
|
+
if ((await fs.readFile(path.join(cache, REVISION_FILE), 'utf8')).trim() === revision) return
|
|
138
|
+
} catch {
|
|
139
|
+
// 本地缓存不完整时重新获取,不能只凭版本标记跳过内容校验。
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
staging = await fs.mkdtemp(path.join(root, '.template-source-'))
|
|
143
|
+
const storedRepository = path.join(root, 'template-repository')
|
|
144
|
+
const reuse = !!await statEntry(storedRepository)
|
|
145
|
+
const repository = reuse ? storedRepository : path.join(staging, 'repository')
|
|
146
|
+
if (reuse) {
|
|
147
|
+
await requireDirectory(repository)
|
|
148
|
+
await requireDirectory(path.join(repository, '.git'))
|
|
149
|
+
await git(['fetch', '--depth=1', '--filter=blob:none', REMOTE, 'master'], repository)
|
|
150
|
+
await git(['reset', '--hard', 'FETCH_HEAD'], repository)
|
|
151
|
+
} else {
|
|
152
|
+
await git(['clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--single-branch',
|
|
153
|
+
'--branch', 'master', REMOTE, repository], staging)
|
|
154
|
+
await git(['sparse-checkout', 'set', '--cone', TEMPLATE_PATH.join('/')], repository)
|
|
155
|
+
await git(['checkout', '--force', 'master'], repository)
|
|
156
|
+
}
|
|
157
|
+
let candidate = repository
|
|
158
|
+
for (const segment of TEMPLATE_PATH) {
|
|
159
|
+
candidate = path.join(candidate, segment)
|
|
160
|
+
await requireDirectory(candidate)
|
|
161
|
+
}
|
|
162
|
+
await validateTemplates(candidate)
|
|
163
|
+
const next = path.join(staging, 'templates')
|
|
164
|
+
await fs.copy(candidate, next)
|
|
165
|
+
await fs.writeFile(path.join(next, REVISION_FILE), await git(['rev-parse', 'HEAD'], repository))
|
|
166
|
+
if (!reuse) await fs.rename(repository, storedRepository)
|
|
167
|
+
const hadCache = !!await statEntry(cache)
|
|
168
|
+
if (hadCache) await fs.rename(cache, previous)
|
|
169
|
+
try {
|
|
170
|
+
await fs.rename(next, cache)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (hadCache) await fs.rename(previous, cache)
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
if (hadCache) await fs.remove(previous)
|
|
176
|
+
} finally {
|
|
177
|
+
try {
|
|
178
|
+
if (staging) await fs.remove(staging)
|
|
179
|
+
} finally {
|
|
180
|
+
await fs.unlink(lockPath)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 从固定公开仓库更新用户模板缓存;只读取已验证的目录,不执行模板脚本。
|
|
187
|
+
* 显式目录优先;DWY_HOME 优先于注入 homeDir,更新失败保留缓存,无有效缓存才用随包模板。
|
|
188
|
+
*/
|
|
189
|
+
export async function resolveTemplateSource({ homeDir = os.homedir() } = {}) {
|
|
190
|
+
const explicitSourceDir = process.env.DWY_TEMPLATE_SOURCE_DIR
|
|
191
|
+
if (explicitSourceDir) {
|
|
192
|
+
if (!await fs.pathExists(explicitSourceDir)) {
|
|
193
|
+
throw new Error(`DWY_TEMPLATE_SOURCE_DIR not found: ${explicitSourceDir}`)
|
|
194
|
+
}
|
|
195
|
+
return explicitSourceDir
|
|
196
|
+
}
|
|
197
|
+
const root = process.env.DWY_HOME || path.join(homeDir, '.dwy')
|
|
198
|
+
const cache = path.join(root, 'template-source')
|
|
199
|
+
const progress = process.stdout.isTTY ? spinner() : null
|
|
200
|
+
progress?.start('正在检查 AI 模板更新,首次获取可能较慢')
|
|
201
|
+
try {
|
|
202
|
+
await refreshTemplates(root, cache)
|
|
203
|
+
progress?.stop('AI 模板已就绪')
|
|
204
|
+
return cache
|
|
205
|
+
} catch (error) {
|
|
206
|
+
progress?.stop('模板更新未完成,正在检查本地来源')
|
|
207
|
+
try {
|
|
208
|
+
await validateTemplates(cache)
|
|
209
|
+
} catch {
|
|
210
|
+
const bundled = path.join(PACKAGE_ROOT, 'templates', 'ai-tools')
|
|
211
|
+
await validateTemplates(bundled)
|
|
212
|
+
console.warn(`模板更新失败(${error.message});无有效缓存,回退随包模板。`)
|
|
213
|
+
return bundled
|
|
214
|
+
}
|
|
215
|
+
console.warn(`模板更新失败(${error.message});继续使用本地缓存。`)
|
|
216
|
+
return cache
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -120,16 +120,16 @@
|
|
|
120
120
|
- 需要用户决策的复杂多步骤流程
|
|
121
121
|
- 用户明确要求详细解释时
|
|
122
122
|
|
|
123
|
-
##
|
|
123
|
+
## 使用新框架或工具前先找配套能力
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
首次使用新的框架、SDK、平台、服务或开发工具时,在操作前必须查找其配套的 Skill、MCP 或 CLI。有适用的就按说明使用,帮助完成设计、实现或验证;没有合适的就按正常方式继续,不强求安装。已核实且仍适用的结果直接复用,普通文案修改或熟悉的小修复不重复检索。
|
|
126
126
|
|
|
127
127
|
### 查找与选择
|
|
128
128
|
|
|
129
|
-
1. 先检查当前已安装、已连接的 Skill、MCP、CLI
|
|
129
|
+
1. 先检查当前已安装、已连接的 Skill、MCP、CLI,以及项目已有工具,寻找与当前框架和任务匹配的能力;匹配时先读使用说明和权限要求。
|
|
130
130
|
2. 再查对应平台、框架或产品的官方文档、官方仓库与工具目录,寻找官方提供或明确推荐的能力;本轮已核实且覆盖当前任务的来源可以复用。
|
|
131
131
|
3. 官方方案不适用或不存在时,查找当前生态中用户基础广、持续维护的开源社区方案。结合维护者身份、近期发布、问题处理、文档和实际使用情况判断成熟度,不能只看 Star 数、下载量或搜索排名。
|
|
132
|
-
4.
|
|
132
|
+
4. 按任务匹配度、风险、接入成本和维护成本决定是否采用。已有工具能完成任务时优先复用;找到合适能力后实际用于当前任务,不能只列出名称。未找到或不适用时简要说明,继续当前任务,不为凑工具而安装或反复扩大检索。
|
|
133
133
|
|
|
134
134
|
Skill 用于工作指导,MCP 用于连接能力,CLI 用于执行操作,按任务选择需要的类型,不要求三类都安装。无法联网或无法核实来源时,明确说明限制,不把未验证的候选当作可信工具执行。
|
|
135
135
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dwy-scope-guard
|
|
3
|
+
description: 用户提出新增功能、顺手扩展、收录想法,或开始规划下一个版本、查看待办想法、担心本期功能过多时使用。适用于持续迭代中的版本范围与想法管理;单纯修复既有行为、执行已确认范围内的任务时不重复评审。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 版本克制与想法收录
|
|
7
|
+
|
|
8
|
+
帮助用户判断想法是否值得现在做,让每个版本先完成可用闭环。敢于指出范围膨胀,把暂缓的想法可靠保存,并在新版本规划时重新提醒。
|
|
9
|
+
|
|
10
|
+
## 工作边界
|
|
11
|
+
|
|
12
|
+
- 针对需求证据和交付取舍提出异议,不评价用户的性格或欲望,也不迎合每个想法。
|
|
13
|
+
- 用户明确限定“只分析、先分析、不改文件”时不开发、不持久化,返回待收录内容;常规想法评估按第 3 节收录。明确要求实施时,给出一次简短范围建议后,在授权范围内执行。不能擅自删除已约定功能;用户听取建议后仍决定继续,不反复劝阻。
|
|
14
|
+
- 安全、数据完整性、输入校验、错误处理、可访问性和必要验证属于可用性,不因控制范围而省略。
|
|
15
|
+
- 提醒发生在本 Skill 被调用、识别到新增功能或新版本规划时。自动触发取决于宿主发现能力;不承诺后台监控、定时或跨会话主动推送,不自行配置通知服务。
|
|
16
|
+
|
|
17
|
+
## 1. 先确认当前版本的负担
|
|
18
|
+
|
|
19
|
+
读取当前产品的版本计划、变更记录、验收记录及相关改动,优先复用已有资料。多包仓库按当前产品或包判断,不把其他包的工作算进来。
|
|
20
|
+
|
|
21
|
+
- 确认上一个已发布或已验收版本、本期目标和已承诺范围;最新提交或标签本身不能证明版本可用。
|
|
22
|
+
- 区分本期已验证、已实现未验证、尚未完成的内容,关注新增用户能力、跨模块依赖、数据迁移和验收成本,不只数功能或代码行数。
|
|
23
|
+
- 缺少基线或进度证据时明确说明,不编造版本差异、工期或完成率。仅在影响决策时问一个关键问题,其余给出带假设的建议。
|
|
24
|
+
|
|
25
|
+
## 2. 每个新想法先给判断
|
|
26
|
+
|
|
27
|
+
结合上下文简短分析:解决谁的什么问题、证据是什么、不做的影响、能否用现有能力满足,以及最小方案引入多少实现、运维和验证负担。无证据的收益标为假设,不机械追问一整套问题。
|
|
28
|
+
|
|
29
|
+
先给结论:**本期做 / 缩小后做 / 放到后续版本 / 先验证需求**,随后说明理由、最小范围和收尾标准。
|
|
30
|
+
|
|
31
|
+
- 新想法支撑本期核心闭环、有实际需求依据且负担可控时,建议本期做。
|
|
32
|
+
- 已有多项未完成或未验收内容、新增独立使用场景、引入较重依赖,或不断以“顺手、以后可能用到、既然做了就一起”扩张时,具体指出对交付的影响,优先建议收尾或拆阶段。
|
|
33
|
+
- 需要拆阶段时,每阶段都要有独立可用结果和可判断的验收条件;后续增强进入清单,不作为本期交付的前置条件。
|
|
34
|
+
- 本期确需增加范围时,说明新增成本,并建议替换或推迟什么;取舍由用户决定。不设任意功能数量上限,不为凑齐固定数量增加需求。
|
|
35
|
+
- 已分析并确认过的同一想法,直接沿用结论;只有范围、证据或依赖发生变化时才重评。
|
|
36
|
+
|
|
37
|
+
## 3. 把想法存到项目里
|
|
38
|
+
|
|
39
|
+
先查项目指定或已有的想法池、backlog、版本路线图,沿用其位置和格式;没有时在项目根创建 `.dwy/ideas.md`。不写进 Skill 安装目录、全局个人记忆或其他项目,不另建重复清单。多产品清单按产品或包分组。
|
|
40
|
+
|
|
41
|
+
用户要求收录,或常规分析产生暂缓想法时,在任务允许写文件的情况下直接记录,不逐条重复确认。明确限定仅分析、纯只读请求或没有写权限时只给待收录内容,明确尚未保存。不得把敏感数据、凭据写入清单,也不因已有外部系统而擅自创建远端任务。
|
|
42
|
+
|
|
43
|
+
记录前按问题和方案查重;已有同一想法时补充证据和决定,不重复新增,不覆盖无关记录。每项保留:
|
|
44
|
+
|
|
45
|
+
- 稳定编号、简短名称、提出日期与来源。
|
|
46
|
+
- 用户问题和需求证据;用户原意与 AI 建议分开,未知项写“待确认”。
|
|
47
|
+
- 最小可用方案、暂缓原因与重评条件。
|
|
48
|
+
- 状态:候选 / 本期已选 / 已交付 / 已放弃;目标版本未定就写“未定”,不自行承诺下版必做。
|
|
49
|
+
|
|
50
|
+
没有既有格式时,可用下列 Markdown 结构,按真实信息填写:
|
|
51
|
+
|
|
52
|
+
```markdown
|
|
53
|
+
# 想法清单
|
|
54
|
+
|
|
55
|
+
## 当前版本
|
|
56
|
+
|
|
57
|
+
- 上一可用版本:待确认
|
|
58
|
+
- 本期目标与范围:待确认
|
|
59
|
+
- 收尾与验收条件:待确认
|
|
60
|
+
|
|
61
|
+
## IDEA-001:想法名称
|
|
62
|
+
|
|
63
|
+
- 提出日期与来源:实际日期;用户原话或需求链接
|
|
64
|
+
- 状态 / 目标版本:候选 / 未定
|
|
65
|
+
- 问题与证据:具体问题;无证据时注明假设
|
|
66
|
+
- 最小方案:最小可用结果;AI 建议单独标明
|
|
67
|
+
- 暂缓原因:当前取舍
|
|
68
|
+
- 重评条件:下一版本规划,或具体反馈、依赖条件满足时
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
已有版本计划时引用它,不重复维护一份当前范围。实际写入后回读相关条目,报告保存位置和编号;写入失败不得声称已收录。仅更新清单不代表功能实现或版本交付。
|
|
72
|
+
|
|
73
|
+
## 4. 新版本规划时重新提醒
|
|
74
|
+
|
|
75
|
+
用户说“开始下个版本、下一版做什么、排版本、还有哪些想法”等时,先读取持久化清单,再提出计划,不依赖聊天记忆。
|
|
76
|
+
|
|
77
|
+
1. 核对上一版实际交付和未完成项;未完成项先明确处置,不默认为已完成或偷偷带入下一版。
|
|
78
|
+
2. 主动提醒候选想法,说明哪些到了重评时机、哪些仍缺证据或依赖;没有到期候选时如实说明。
|
|
79
|
+
3. 结合新反馈和本期目标推荐少量最相关项,解释取舍,避免把整个想法池塞进版本。用户未选定前保持候选状态。
|
|
80
|
+
4. 用户确定范围后,记录本期目标、已选编号、最小范围和收尾标准,更新对应状态及目标版本。用户决定暂缓或撤回本期项时恢复候选并保留原因;同一轮规划不反复提醒已决定的事项。
|
|
81
|
+
5. 只有对应验收证据成立才标为已交付;未验收的实现仍保留未完成说明。用户明确放弃时标为已放弃并保留原因,不删除历史、不再次推荐;用户主动重提时再更新。
|
|
82
|
+
|
|
83
|
+
清单不存在时说明没有可读取的历史想法,不编造遗漏记录。不要只因查询清单就创建空文件。
|
|
84
|
+
|
|
85
|
+
## 输出示例
|
|
86
|
+
|
|
87
|
+
用户:“记账功能还没验收,我还想这版一起加 AI 分析、家庭共享和多币种。”
|
|
88
|
+
|
|
89
|
+
> **建议:先完成本期验收,三个想法放到后续版本。** 基础录入尚未验收,家庭共享会增加权限和同步,多币种会增加换算与数据处理,都会扩大本期验证范围。先走通“录入—保存—查看”及关键失败路径;三个想法分别收录,需求证据先标为待确认。开始规划下一版时,再结合反馈提醒并重新排序。
|
|
90
|
+
|
|
91
|
+
只有实际保存后才补充收录位置与编号;此示例不代表已经写入想法清单。
|