create-dwy 0.21.0 → 0.22.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/bin/index.js +6 -3
- package/package.json +1 -1
- package/src/cli-help.js +9 -2
- package/src/self-upgrade.js +7 -0
- package/src/skill-store.js +182 -0
- package/src/sync-all.js +134 -32
- package/src/sync-codex.js +15 -5
- package/src/sync-global-skills.js +65 -14
- package/src/sync-opencode.js +9 -5
- package/src/sync.js +13 -4
- package/templates/ai-tools/AGENTS.md +1 -1
- package/templates/ai-tools/CHANGELOG.md +6 -0
- package/templates/ai-tools/VERSION +1 -1
- package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/progr/sql/createExternalTable.md +2 -2
- package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tools/superset.md +1 -1
- package/templates/ai-tools/skills/DolphinDB/dwy-dolphindb/references/official/tutorials/Migrate_data_from_InfluxDB_to_DolphinDB.md +1 -1
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +18 -25
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +3 -3
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/backend-deploy.md +4 -4
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +6 -70
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/frontend-deploy.md +3 -3
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/generic-release.md +5 -5
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/git-tag.md +4 -79
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +2 -2
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +3 -3
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/publish-config.md +4 -4
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/version-sync.md +7 -7
- package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +3 -7
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/SKILL.md +58 -0
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/delegation-guide.md +46 -0
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-development-review/references/review-guide.md +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# create-dwy
|
|
2
2
|
|
|
3
|
+
## [0.22.0] - 2026-11-02
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Skills 改为用户目录 `.dwy/skills/<name>` 内的一份共享副本,项目和全局入口使用整个目录的软链接;同平台已有有效全局入口时清理项目重复入口。原真实目录先备份,自定义 Skills、Rules 和 Hooks 保留。
|
|
8
|
+
- 新增 `dwy migrate`,在老项目根目录一键迁移已有托管 Skills,无需重新勾选;重复执行不重复备份,已移除的历史模板和已有整个 Skills 根目录链接保留并报告。
|
|
9
|
+
- 新增 `dwy refresh`,使用当前 CLI 包内模板刷新所有已安装的共享副本;`dwy upgrade` 升级成功后自动调用新进程刷新,所有已迁移项目无需分别更新。
|
|
10
|
+
- 共享副本刷新加入互斥及整批失败恢复;扫描和清理统一保护目录链接目标,防止自链和穿透根链接误删文件。全局默认选择覆盖自定义 `CODEX_HOME`。
|
|
11
|
+
- CHANGELOG 规则明确要求每个版本条目包含版本号和日期,禁止 `Unreleased` 等占位标题,与是否发布无关;发布记录提取兼容带日期和方括号的版本标题。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
3
15
|
## 0.21.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/bin/index.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { readFileSync } from 'fs'
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
import { dirname, resolve } from 'path'
|
|
6
|
-
import { runDwy } from '../src/sync-all.js'
|
|
6
|
+
import { runDwy, migrateSkills } from '../src/sync-all.js'
|
|
7
|
+
import { refreshSharedSkills } from '../src/skill-store.js'
|
|
7
8
|
import { selfUpgrade } from '../src/self-upgrade.js'
|
|
8
9
|
import { buildHelpText } from '../src/cli-help.js'
|
|
9
10
|
|
|
@@ -24,14 +25,16 @@ if (wantsHelp(argv)) {
|
|
|
24
25
|
printHelp()
|
|
25
26
|
} else if (argv.includes('--version') || argv.includes('-V')) {
|
|
26
27
|
console.log(pkg.version)
|
|
27
|
-
} else if (
|
|
28
|
+
} else if (['upgrade', 'refresh', 'migrate'].includes(argv[0])) {
|
|
28
29
|
if (argv.length > 1) {
|
|
29
30
|
console.error(`未知命令: dwy ${argv.join(' ')}`)
|
|
30
31
|
printHelp()
|
|
31
32
|
process.exitCode = 1
|
|
32
33
|
} else {
|
|
33
34
|
try {
|
|
34
|
-
await selfUpgrade()
|
|
35
|
+
if (argv[0] === 'upgrade') await selfUpgrade()
|
|
36
|
+
if (argv[0] === 'refresh') await refreshSharedSkills()
|
|
37
|
+
if (argv[0] === 'migrate') await migrateSkills()
|
|
35
38
|
} catch (error) {
|
|
36
39
|
console.error(error.message)
|
|
37
40
|
process.exitCode = 1
|
package/package.json
CHANGED
package/src/cli-help.js
CHANGED
|
@@ -16,7 +16,9 @@ export function buildHelpText(version) {
|
|
|
16
16
|
'用法:',
|
|
17
17
|
' dwy 全局更新通用基线,项目注入所选 Rules;Skills / Hooks 可选位置',
|
|
18
18
|
' dwy sync 同上',
|
|
19
|
-
' dwy upgrade
|
|
19
|
+
' dwy upgrade 升级到 npm 最新正式版,并刷新共享 Skills',
|
|
20
|
+
' dwy refresh 用当前 CLI 模板刷新已安装的共享 Skills',
|
|
21
|
+
' dwy migrate 一键迁移老项目已有的托管 Skills,原目录先备份',
|
|
20
22
|
' dwy --help 显示本说明',
|
|
21
23
|
' dwy --version 显示版本',
|
|
22
24
|
'',
|
|
@@ -37,6 +39,8 @@ export function buildHelpText(version) {
|
|
|
37
39
|
' 同步全局 Skills / Hooks:在项目目录执行 dwy,在位置步骤选择「全局」。',
|
|
38
40
|
' 更新或调整配置:再次执行 dwy sync,检查勾选项,再确认同步。',
|
|
39
41
|
' 更新 CLI:dwy upgrade(全局安装时可用;更新后执行 dwy --version 查看版本)。',
|
|
42
|
+
' 迁移老项目:cd your-project 后执行 dwy migrate,无需重新勾选。',
|
|
43
|
+
' 手动升级包后:执行一次 dwy refresh,所有已迁移的链接同时生效。',
|
|
40
44
|
' 首次安装:npm install -g create-dwy@latest',
|
|
41
45
|
'',
|
|
42
46
|
'平台支持:',
|
|
@@ -47,7 +51,10 @@ export function buildHelpText(version) {
|
|
|
47
51
|
'',
|
|
48
52
|
'同步仅处理所选平台;基线固定全局、Rules 固定项目,位置选择只影响 Skills / Hooks。',
|
|
49
53
|
'取消已勾选的托管项会在确认后删除,保留手写内容。',
|
|
50
|
-
'
|
|
54
|
+
'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
|
|
55
|
+
'旧真实目录备份到同步根的 .dwy/skill-backups;个人 Skills、Rules、Hooks 不参与 migrate。',
|
|
56
|
+
'旧项目首次需执行 dwy migrate;迁移后无需逐项目刷新 Skills。',
|
|
57
|
+
'Rules / Hooks 的模板更新仍需执行 dwy sync;已有会话缓存的 Skill 可能需要重新加载。',
|
|
51
58
|
'',
|
|
52
59
|
].join('\n')
|
|
53
60
|
}
|
package/src/self-upgrade.js
CHANGED
|
@@ -141,6 +141,7 @@ export async function selfUpgrade({
|
|
|
141
141
|
const latest = await fetchLatest()
|
|
142
142
|
const cmp = compareVersions(current, latest)
|
|
143
143
|
if (cmp === 0) {
|
|
144
|
+
await runCommand('dwy', ['refresh'])
|
|
144
145
|
console.log(chalk.green(`已是最新正式版 ${current}`))
|
|
145
146
|
return { status: 'up-to-date', current, latest, installer }
|
|
146
147
|
}
|
|
@@ -153,6 +154,12 @@ export async function selfUpgrade({
|
|
|
153
154
|
console.log(chalk.blue(`升级 ${PACKAGE_NAME} ${current} → ${latest}`))
|
|
154
155
|
console.log(chalk.gray(` ${cmd} ${args.join(' ')}`))
|
|
155
156
|
await runCommand(cmd, args)
|
|
157
|
+
try {
|
|
158
|
+
// 必须启动新安装的 CLI;pnpm 升级后旧进程的模块路径仍可能指向旧版本。
|
|
159
|
+
await runCommand('dwy', ['refresh'])
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(`CLI 已安装新版本,但共享 Skills 刷新失败:${error.message}`, { cause: error })
|
|
162
|
+
}
|
|
156
163
|
console.log(chalk.green(`已升级到 ${latest}`))
|
|
157
164
|
return { status: 'upgraded', current, latest, installer }
|
|
158
165
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { copyFilter, resolveSourceDir, scanSkills } from './sync.js'
|
|
6
|
+
import { chalk } from './utils.js'
|
|
7
|
+
|
|
8
|
+
function validateName(name) {
|
|
9
|
+
if (!/^[\p{L}\p{N}_-]+$/u.test(name)) throw new Error(`无效 Skill 名称:${name}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readInstalledNames(homeDir) {
|
|
13
|
+
const file = path.join(homeDir, '.dwy', 'skill-store.json')
|
|
14
|
+
if (!await fs.pathExists(file)) return []
|
|
15
|
+
const names = await fs.readJson(file)
|
|
16
|
+
if (!Array.isArray(names) || names.some(name => typeof name !== 'string')) {
|
|
17
|
+
throw new Error(`共享 Skills 清单格式无效:${file}`)
|
|
18
|
+
}
|
|
19
|
+
names.forEach(validateName)
|
|
20
|
+
return names
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 将包内 Skills 准备到稳定用户目录,返回指向共享副本的条目。
|
|
25
|
+
* 同一用户的刷新互斥;完整副本准备失败时不替换旧内容。外部 Skills 缓存不纳入本模块清单。
|
|
26
|
+
*/
|
|
27
|
+
export async function prepareSharedSkills({ skills, homeDir = os.homedir() }) {
|
|
28
|
+
if (skills.length === 0) return []
|
|
29
|
+
for (const skill of skills) {
|
|
30
|
+
validateName(skill.name)
|
|
31
|
+
if (!(await fs.stat(path.join(skill.sourcePath, 'SKILL.md'))).isFile()) {
|
|
32
|
+
throw new Error(`Skill 缺少有效 SKILL.md:${skill.sourcePath}`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const root = path.join(homeDir, '.dwy')
|
|
36
|
+
const store = path.join(root, 'skills')
|
|
37
|
+
await fs.ensureDir(store)
|
|
38
|
+
const lockPath = path.join(root, 'skill-store.lock')
|
|
39
|
+
let lock
|
|
40
|
+
try {
|
|
41
|
+
lock = await fs.open(lockPath, 'wx')
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'EEXIST') throw new Error(`另一个进程正在刷新 Skills;若前次异常退出,请移除 ${lockPath} 后重试`)
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
let staging
|
|
47
|
+
let preserveStaging = false
|
|
48
|
+
try {
|
|
49
|
+
staging = await fs.mkdtemp(path.join(root, '.skills-'))
|
|
50
|
+
const installed = new Set(await readInstalledNames(homeDir))
|
|
51
|
+
const prepared = []
|
|
52
|
+
const replacements = []
|
|
53
|
+
for (const skill of skills) {
|
|
54
|
+
const dest = path.join(store, skill.name)
|
|
55
|
+
const next = path.join(staging, skill.name)
|
|
56
|
+
if (path.resolve(skill.sourcePath) !== path.resolve(dest)) {
|
|
57
|
+
await fs.copy(skill.sourcePath, next, { dereference: true, filter: copyFilter(skill.sourcePath) })
|
|
58
|
+
replacements.push({ dest, next, old: path.join(staging, `${skill.name}.old`), saved: false, replaced: false })
|
|
59
|
+
}
|
|
60
|
+
installed.add(skill.name)
|
|
61
|
+
prepared.push({ ...skill, sourcePath: dest })
|
|
62
|
+
}
|
|
63
|
+
const manifest = path.join(staging, 'skill-store.json')
|
|
64
|
+
await fs.writeJson(manifest, [...installed].sort(), { spaces: 2 })
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of replacements) {
|
|
67
|
+
const existing = await fs.lstat(entry.dest).catch(error => {
|
|
68
|
+
if (error.code === 'ENOENT') return null
|
|
69
|
+
throw error
|
|
70
|
+
})
|
|
71
|
+
if (existing) {
|
|
72
|
+
await fs.rename(entry.dest, entry.old)
|
|
73
|
+
entry.saved = true
|
|
74
|
+
}
|
|
75
|
+
await fs.rename(entry.next, entry.dest)
|
|
76
|
+
entry.replaced = true
|
|
77
|
+
}
|
|
78
|
+
await fs.rename(manifest, path.join(root, 'skill-store.json'))
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const errors = [error]
|
|
81
|
+
for (const entry of [...replacements].reverse()) {
|
|
82
|
+
try {
|
|
83
|
+
if (entry.replaced) await fs.remove(entry.dest)
|
|
84
|
+
if (entry.saved) await fs.rename(entry.old, entry.dest)
|
|
85
|
+
} catch (restoreError) {
|
|
86
|
+
errors.push(restoreError)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (errors.length > 1) {
|
|
90
|
+
preserveStaging = true
|
|
91
|
+
throw new AggregateError(errors, `刷新失败且自动恢复未完成,旧副本保留在 ${staging}`)
|
|
92
|
+
}
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
return prepared
|
|
96
|
+
} finally {
|
|
97
|
+
try {
|
|
98
|
+
if (staging && !preserveStaging) await fs.remove(staging)
|
|
99
|
+
} finally {
|
|
100
|
+
await fs.close(lock)
|
|
101
|
+
await fs.remove(lockPath)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function entryParent(dest, backupRoot) {
|
|
107
|
+
const parent = await fs.realpath(path.dirname(dest))
|
|
108
|
+
const boundary = path.resolve(backupRoot, '..', '..')
|
|
109
|
+
const realBoundary = await fs.realpath(boundary)
|
|
110
|
+
const expected = path.join(realBoundary, path.relative(boundary, path.dirname(dest)))
|
|
111
|
+
if (parent !== expected) throw new Error(`Skills 根目录链接指向外部位置,保留原入口,不修改目标:${path.dirname(dest)}`)
|
|
112
|
+
return parent
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 清理托管入口:真实目录移到备份根,软链接仅移除入口,不遍历其目标。
|
|
117
|
+
* 返回真实目录的备份路径;入口不存在或原为链接时返回 null。
|
|
118
|
+
*/
|
|
119
|
+
export async function removeSkillEntry({ dest, backupRoot }) {
|
|
120
|
+
const stat = await fs.lstat(dest).catch(error => {
|
|
121
|
+
if (error.code === 'ENOENT') return null
|
|
122
|
+
throw error
|
|
123
|
+
})
|
|
124
|
+
if (!stat) return null
|
|
125
|
+
await entryParent(dest, backupRoot)
|
|
126
|
+
if (stat.isSymbolicLink()) {
|
|
127
|
+
await fs.unlink(dest)
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
await fs.ensureDir(backupRoot)
|
|
131
|
+
const backup = path.join(backupRoot, `${path.basename(dest)}-${randomUUID()}`)
|
|
132
|
+
await fs.rename(dest, backup)
|
|
133
|
+
console.log(chalk.gray(`已备份 ${dest} → ${backup}`))
|
|
134
|
+
return backup
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 将整个 Skill 目录链接到共享副本;原真实目录先备份,链接失败时恢复原入口。 */
|
|
138
|
+
export async function linkSkill({ skill, dest, backupRoot }) {
|
|
139
|
+
if (!(await fs.stat(path.join(skill.sourcePath, 'SKILL.md'))).isFile()) {
|
|
140
|
+
throw new Error(`Skill 缺少有效 SKILL.md:${skill.sourcePath}`)
|
|
141
|
+
}
|
|
142
|
+
await fs.ensureDir(path.dirname(dest))
|
|
143
|
+
const actualParent = await fs.realpath(path.dirname(dest))
|
|
144
|
+
// 整个全局 skills 根可能已链接到共享目录;此时入口就是源目录,不能把它再改为自链。
|
|
145
|
+
if (path.join(actualParent, path.basename(dest)) === await fs.realpath(skill.sourcePath)) return null
|
|
146
|
+
await entryParent(dest, backupRoot)
|
|
147
|
+
const stat = await fs.lstat(dest).catch(error => {
|
|
148
|
+
if (error.code === 'ENOENT') return null
|
|
149
|
+
throw error
|
|
150
|
+
})
|
|
151
|
+
const previousLink = stat?.isSymbolicLink() ? await fs.readlink(dest) : null
|
|
152
|
+
if (previousLink && path.resolve(path.dirname(dest), previousLink) === path.resolve(skill.sourcePath)) return null
|
|
153
|
+
const temporary = path.join(path.dirname(dest), `.${path.basename(dest)}.link-${randomUUID()}`)
|
|
154
|
+
await fs.symlink(path.resolve(skill.sourcePath), temporary, process.platform === 'win32' ? 'junction' : 'dir')
|
|
155
|
+
let backup
|
|
156
|
+
try {
|
|
157
|
+
backup = await removeSkillEntry({ dest, backupRoot })
|
|
158
|
+
try {
|
|
159
|
+
await fs.rename(temporary, dest)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (backup) await fs.rename(backup, dest)
|
|
162
|
+
else if (previousLink) await fs.symlink(previousLink, dest, process.platform === 'win32' ? 'junction' : 'dir')
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
165
|
+
return backup
|
|
166
|
+
} finally {
|
|
167
|
+
await fs.remove(temporary)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 使用当前 CLI 包内模板刷新已有共享副本;新包移除的条目保留,避免其他项目坏链。 */
|
|
172
|
+
export async function refreshSharedSkills({ sourceDir, homeDir = os.homedir() } = {}) {
|
|
173
|
+
const names = await readInstalledNames(homeDir)
|
|
174
|
+
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
175
|
+
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
176
|
+
const updated = names.filter(name => available.has(name))
|
|
177
|
+
const retained = names.filter(name => !available.has(name))
|
|
178
|
+
await prepareSharedSkills({ skills: updated.map(name => available.get(name)), homeDir })
|
|
179
|
+
for (const name of retained) console.log(chalk.yellow(`当前 CLI 已移除 ${name},保留共享旧副本以免其他项目链接失效。`))
|
|
180
|
+
console.log(chalk.green(`已刷新 ${updated.length} 个共享 Skills,保留 ${retained.length} 个旧副本。`))
|
|
181
|
+
return { updated, retained }
|
|
182
|
+
}
|
package/src/sync-all.js
CHANGED
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
copySkillsToProjectPlatforms,
|
|
9
9
|
normalizeSkillScope,
|
|
10
10
|
resolveProjectSkillDir,
|
|
11
|
+
resolveGlobalSkillDir,
|
|
12
|
+
deduplicateProjectSkills,
|
|
13
|
+
findGlobalSkill,
|
|
14
|
+
scanGlobalSkills,
|
|
11
15
|
} from './sync-global-skills.js'
|
|
16
|
+
import { prepareSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
12
17
|
import { chalk } from './utils.js'
|
|
13
18
|
import {
|
|
14
19
|
interactiveSelect,
|
|
@@ -117,7 +122,7 @@ export function buildSelectionDefaultsFromSyncState(existing, syncState) {
|
|
|
117
122
|
return hasSetItems(stateDefaults) ? stateDefaults : existing
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
async function scanCombinedExisting(projectDir) {
|
|
125
|
+
async function scanCombinedExisting(projectDir, global = false) {
|
|
121
126
|
const claudeDir = path.join(projectDir, '.claude')
|
|
122
127
|
const agentsMdPath = path.join(projectDir, 'AGENTS.md')
|
|
123
128
|
const agentsMdContent = await fs.pathExists(agentsMdPath) ? await fs.readFile(agentsMdPath, 'utf-8') : ''
|
|
@@ -126,6 +131,7 @@ async function scanCombinedExisting(projectDir) {
|
|
|
126
131
|
skills: unionSets(
|
|
127
132
|
await scanExisting(claudeDir, 'skills'),
|
|
128
133
|
await scanExistingCodexSkills(projectDir),
|
|
134
|
+
await scanExisting(path.join(projectDir, ...(global ? ['.config', 'opencode'] : ['.opencode'])), 'skills'),
|
|
129
135
|
),
|
|
130
136
|
rules: unionSets(
|
|
131
137
|
await scanExisting(claudeDir, 'rules'),
|
|
@@ -174,6 +180,9 @@ async function syncSkillsOnly({
|
|
|
174
180
|
templateSkillNames,
|
|
175
181
|
staleSkillNames,
|
|
176
182
|
skillScope,
|
|
183
|
+
homeDir,
|
|
184
|
+
global,
|
|
185
|
+
duplicateNames,
|
|
177
186
|
}) {
|
|
178
187
|
const writeProject = skillScope.destinations.includes('project')
|
|
179
188
|
if (!writeProject) return
|
|
@@ -185,24 +194,25 @@ async function syncSkillsOnly({
|
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
console.log(chalk.blue('\nSyncing skills only (rules / commands / hooks untouched)...\n'))
|
|
188
|
-
await copySkillsToProjectPlatforms({
|
|
189
|
-
projectDir,
|
|
190
|
-
platforms: skillPlatforms,
|
|
191
|
-
skills,
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
const selectedNames = new Set(skills.map(item => item.name))
|
|
195
|
-
const toRemove = new Set(
|
|
196
|
-
[...templateSkillNames, ...staleSkillNames].filter(name => !selectedNames.has(name)),
|
|
197
|
-
)
|
|
198
197
|
for (const platform of skillPlatforms) {
|
|
199
|
-
const
|
|
198
|
+
const platformSkills = skills.filter(skill => !duplicateNames[platform]?.has(skill.name))
|
|
199
|
+
if (global) {
|
|
200
|
+
await copySkillsToGlobalDirs({ skills: platformSkills, destIds: [platform === 'codex' ? 'agents' : platform], homeDir })
|
|
201
|
+
} else {
|
|
202
|
+
await copySkillsToProjectPlatforms({ projectDir, platforms: [platform], skills: platformSkills, homeDir })
|
|
203
|
+
}
|
|
204
|
+
const selectedNames = new Set(platformSkills.map(item => item.name))
|
|
205
|
+
const toRemove = new Set([...templateSkillNames, ...staleSkillNames].filter(name => !selectedNames.has(name)))
|
|
206
|
+
const destRoot = global
|
|
207
|
+
? resolveGlobalSkillDir(platform === 'codex' ? 'agents' : platform, homeDir)
|
|
208
|
+
: resolveProjectSkillDir(projectDir, platform)
|
|
200
209
|
const existingNames = await scanExisting(path.dirname(destRoot), 'skills')
|
|
201
210
|
await removeManagedItems(
|
|
202
211
|
destRoot,
|
|
203
212
|
existingNames,
|
|
204
213
|
toRemove,
|
|
205
214
|
path.relative(projectDir, destRoot),
|
|
215
|
+
path.join(global ? homeDir : projectDir, '.dwy', 'skill-backups'),
|
|
206
216
|
)
|
|
207
217
|
}
|
|
208
218
|
}
|
|
@@ -381,12 +391,12 @@ function buildNextPlatformState(selected, staleEntries, approvedRemovals, platfo
|
|
|
381
391
|
return state
|
|
382
392
|
}
|
|
383
393
|
|
|
384
|
-
async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix) {
|
|
394
|
+
async function removeManagedItems(targetDir, existingNames, managedNames, labelPrefix, backupRoot) {
|
|
385
395
|
let removedCount = 0
|
|
386
396
|
|
|
387
397
|
for (const name of existingNames) {
|
|
388
398
|
if (!managedNames.has(name)) continue
|
|
389
|
-
await
|
|
399
|
+
await removeSkillEntry({ dest: path.join(targetDir, name), backupRoot })
|
|
390
400
|
logAction(`${labelPrefix}/${name}`, 'red', '×')
|
|
391
401
|
removedCount++
|
|
392
402
|
}
|
|
@@ -419,6 +429,62 @@ export async function runDwy(opts = {}) {
|
|
|
419
429
|
await syncAll(opts)
|
|
420
430
|
}
|
|
421
431
|
|
|
432
|
+
/**
|
|
433
|
+
* 一键迁移当前项目已有的 CLI Skills,不进入选择流程、不补装其他平台、不修改 Rules / Hooks。
|
|
434
|
+
* 仅当前模板或历史同步记录中的名称视为托管;已移除的历史模板和整个根目录链接保留并报告。
|
|
435
|
+
*/
|
|
436
|
+
export async function migrateSkills({
|
|
437
|
+
sourceDir,
|
|
438
|
+
projectDir = process.cwd(),
|
|
439
|
+
homeDir = os.homedir(),
|
|
440
|
+
codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex'),
|
|
441
|
+
} = {}) {
|
|
442
|
+
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
443
|
+
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
444
|
+
const state = await loadSyncState(projectDir)
|
|
445
|
+
const existingByPlatform = {}
|
|
446
|
+
const neededNames = new Set()
|
|
447
|
+
const retained = []
|
|
448
|
+
const realProject = await fs.realpath(projectDir)
|
|
449
|
+
for (const platform of ['claude', 'codex', 'opencode']) {
|
|
450
|
+
const root = resolveProjectSkillDir(projectDir, platform)
|
|
451
|
+
if (!await fs.pathExists(root)) continue
|
|
452
|
+
if (await fs.realpath(root) !== path.join(realProject, path.relative(projectDir, root))) {
|
|
453
|
+
console.log(chalk.yellow(`${root} 已通过根目录链接共享内容,保留原链接,不迁移其目标。`))
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
const existing = await scanExisting(path.dirname(root), 'skills')
|
|
457
|
+
const historical = new Set(state.platforms[platform]?.skills || [])
|
|
458
|
+
const managed = [...existing].filter(name => available.has(name) || historical.has(name))
|
|
459
|
+
if (managed.length === 0) continue
|
|
460
|
+
existingByPlatform[platform] = managed
|
|
461
|
+
for (const name of managed) {
|
|
462
|
+
if (available.has(name)) neededNames.add(name)
|
|
463
|
+
else {
|
|
464
|
+
retained.push({ platform, name })
|
|
465
|
+
console.log(chalk.yellow(`${platform}/${name} 已不在当前模板中,保留原目录。`))
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const shared = await prepareSharedSkills({ skills: [...neededNames].map(name => available.get(name)), homeDir })
|
|
470
|
+
let linked = 0
|
|
471
|
+
let globalCount = 0
|
|
472
|
+
for (const [platform, names] of Object.entries(existingByPlatform)) {
|
|
473
|
+
const skills = shared.filter(skill => names.includes(skill.name))
|
|
474
|
+
const duplicates = await deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome })
|
|
475
|
+
const local = skills.filter(skill => !duplicates.has(skill.name))
|
|
476
|
+
linked += await copySkillsToProjectPlatforms({ projectDir, platforms: [platform], skills: local, homeDir })
|
|
477
|
+
globalCount += duplicates.size
|
|
478
|
+
state.platforms[platform] = {
|
|
479
|
+
...(state.platforms[platform] || createEmptyPlatformState()),
|
|
480
|
+
skills: names.filter(name => !duplicates.has(name)).sort(),
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (Object.keys(existingByPlatform).length) await saveSyncState(projectDir, state)
|
|
484
|
+
console.log(chalk.green(`迁移完成:${linked} 个项目链接,${globalCount} 个使用全局入口,${retained.length} 个历史条目保留。`))
|
|
485
|
+
return { linked, global: globalCount, retained }
|
|
486
|
+
}
|
|
487
|
+
|
|
422
488
|
export async function syncAll({
|
|
423
489
|
sourceDir: sourceDirOverride,
|
|
424
490
|
projectDir: projectDirOverride,
|
|
@@ -452,7 +518,10 @@ export async function syncAll({
|
|
|
452
518
|
const syncState = await loadSyncState(projectDir)
|
|
453
519
|
const globalState = await loadSyncState(homeDir)
|
|
454
520
|
const projectDefaults = buildSelectionDefaultsFromSyncState(existing, syncState)
|
|
455
|
-
const
|
|
521
|
+
const globalExisting = await scanCombinedExisting(homeDir, true)
|
|
522
|
+
globalExisting.skills = await scanGlobalSkills({ homeDir, codexHome })
|
|
523
|
+
const globalDefaults = buildSelectionDefaultsFromSyncState(globalExisting, globalState)
|
|
524
|
+
globalDefaults.skills = unionSets(globalDefaults.skills, globalExisting.skills)
|
|
456
525
|
const selectionDefaults = Object.fromEntries(['skills', 'rules', 'hooks'].map(type => [type, unionSets(projectDefaults[type], globalDefaults[type])]))
|
|
457
526
|
selectionDefaults.rules = projectDefaults.rules
|
|
458
527
|
const syncMode = normalizeSyncMode(syncModeOverride)
|
|
@@ -529,6 +598,7 @@ export async function syncAll({
|
|
|
529
598
|
for (const dest of legacyClaudeRules) console.log(`删除旧 Claude Rules → ${dest}`)
|
|
530
599
|
}
|
|
531
600
|
console.log('Hooks 按支持的平台自动启用。')
|
|
601
|
+
console.log('Skills 将迁为用户共享副本的目录链接;原真实目录先备份,同平台全局入口优先。')
|
|
532
602
|
for (const platform of selectedPlatforms) {
|
|
533
603
|
for (const type of PLATFORM_SYNC_TYPES[platform]) {
|
|
534
604
|
const existingNames = currentManaged[platform]?.[type] || new Set()
|
|
@@ -548,7 +618,32 @@ export async function syncAll({
|
|
|
548
618
|
if (!selectedOverride && selected.skills.some(item => item.category === '产品0到1')) await ensureSkillsInstalled()
|
|
549
619
|
|
|
550
620
|
const selectedSkillNames = new Set((selected.skills || []).map(item => item.name))
|
|
551
|
-
const
|
|
621
|
+
const globalSkillNameSet = new Set(skillScope.globalSkills)
|
|
622
|
+
const neededNames = new Set(skillScope.destinations.some(id => id !== 'project') ? globalSkillNameSet : [])
|
|
623
|
+
if (selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))) {
|
|
624
|
+
for (const name of selectedSkillNames) neededNames.add(name)
|
|
625
|
+
}
|
|
626
|
+
for (const platform of selectedPlatforms) {
|
|
627
|
+
for (const skill of scans.skills) {
|
|
628
|
+
if (await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })) neededNames.add(skill.name)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const sharedSkills = await prepareSharedSkills({ skills: scans.skills.filter(item => neededNames.has(item.name)), homeDir })
|
|
632
|
+
const skillsToWrite = sharedSkills.filter(item => selectedSkillNames.has(item.name))
|
|
633
|
+
const globalSkills = sharedSkills.filter(item => globalSkillNameSet.has(item.name))
|
|
634
|
+
if (globalSkills.length > 0 && skillScope.destinations.some(id => id !== 'project')) {
|
|
635
|
+
await copySkillsToGlobalDirs({ skills: globalSkills, destIds: skillScope.destinations, homeDir })
|
|
636
|
+
}
|
|
637
|
+
const duplicateNames = {}
|
|
638
|
+
if (!global) {
|
|
639
|
+
for (const platform of selectedPlatforms) {
|
|
640
|
+
duplicateNames[platform] = await deduplicateProjectSkills({ projectDir, platform, skills: sharedSkills, homeDir, codexHome })
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const selectionFor = platform => ({
|
|
644
|
+
...selected,
|
|
645
|
+
skills: selected.skills.filter(skill => !duplicateNames[platform]?.has(skill.name)),
|
|
646
|
+
})
|
|
552
647
|
let syncedAnySupportedPlatform = false
|
|
553
648
|
|
|
554
649
|
if (syncMode === SYNC_MODE_SKILLS) {
|
|
@@ -563,6 +658,9 @@ export async function syncAll({
|
|
|
563
658
|
templateSkillNames: new Set(scans.skills.map(item => item.name)),
|
|
564
659
|
staleSkillNames,
|
|
565
660
|
skillScope,
|
|
661
|
+
homeDir,
|
|
662
|
+
global,
|
|
663
|
+
duplicateNames,
|
|
566
664
|
})
|
|
567
665
|
syncedAnySupportedPlatform = selectedPlatforms.some(platform => resolveProjectSkillDir(projectDir, platform))
|
|
568
666
|
|| skillScope.destinations.some(id => id !== 'project')
|
|
@@ -586,7 +684,9 @@ export async function syncAll({
|
|
|
586
684
|
sourceDir,
|
|
587
685
|
projectDir: targetRoot,
|
|
588
686
|
global,
|
|
589
|
-
selected,
|
|
687
|
+
selected: selectionFor('claude'),
|
|
688
|
+
homeDir,
|
|
689
|
+
sharedSkills,
|
|
590
690
|
staleRemovals: approvedStaleRemovals.claude,
|
|
591
691
|
})
|
|
592
692
|
}
|
|
@@ -598,7 +698,9 @@ export async function syncAll({
|
|
|
598
698
|
sourceDir,
|
|
599
699
|
projectDir: targetRoot,
|
|
600
700
|
global,
|
|
601
|
-
selected,
|
|
701
|
+
selected: selectionFor('codex'),
|
|
702
|
+
homeDir,
|
|
703
|
+
sharedSkills,
|
|
602
704
|
staleRemovals: approvedStaleRemovals.codex,
|
|
603
705
|
})
|
|
604
706
|
}
|
|
@@ -619,7 +721,9 @@ export async function syncAll({
|
|
|
619
721
|
sourceDir,
|
|
620
722
|
projectDir: targetRoot,
|
|
621
723
|
global,
|
|
622
|
-
selected,
|
|
724
|
+
selected: selectionFor('opencode'),
|
|
725
|
+
homeDir,
|
|
726
|
+
sharedSkills,
|
|
623
727
|
staleRemovals: approvedStaleRemovals.opencode,
|
|
624
728
|
})
|
|
625
729
|
}
|
|
@@ -627,21 +731,19 @@ export async function syncAll({
|
|
|
627
731
|
|
|
628
732
|
}
|
|
629
733
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
homeDir,
|
|
638
|
-
})
|
|
734
|
+
if (global) {
|
|
735
|
+
for (const platform of selectedPlatforms) {
|
|
736
|
+
duplicateNames[platform] = await deduplicateProjectSkills({ projectDir, platform, skills: sharedSkills, homeDir, codexHome })
|
|
737
|
+
if (syncState.platforms[platform]) {
|
|
738
|
+
syncState.platforms[platform].skills = syncState.platforms[platform].skills.filter(name => !duplicateNames[platform].has(name))
|
|
739
|
+
}
|
|
740
|
+
}
|
|
639
741
|
}
|
|
640
742
|
|
|
641
743
|
if (syncMode === SYNC_MODE_SKILLS) {
|
|
642
744
|
for (const platform of selectedPlatforms) {
|
|
643
|
-
const previous =
|
|
644
|
-
const skillsOnlySelected = { skills: selected.skills
|
|
745
|
+
const previous = targetState.platforms[platform] || createEmptyPlatformState()
|
|
746
|
+
const skillsOnlySelected = { skills: (global ? selected : selectionFor(platform)).skills, rules: [], commands: [], hooks: [] }
|
|
645
747
|
targetState.platforms[platform] = {
|
|
646
748
|
...previous,
|
|
647
749
|
skills: buildNextPlatformState(skillsOnlySelected, staleEntries, approvedStaleRemovals, platform).skills,
|
|
@@ -649,7 +751,7 @@ export async function syncAll({
|
|
|
649
751
|
}
|
|
650
752
|
} else {
|
|
651
753
|
for (const platform of selectedPlatforms) {
|
|
652
|
-
const next = buildNextPlatformState(selected, staleEntries, approvedStaleRemovals, platform)
|
|
754
|
+
const next = buildNextPlatformState(global ? selected : selectionFor(platform), staleEntries, approvedStaleRemovals, platform)
|
|
653
755
|
if (global) {
|
|
654
756
|
syncState.platforms[platform] = {
|
|
655
757
|
...(syncState.platforms[platform] || createEmptyPlatformState()),
|
|
@@ -675,7 +777,7 @@ export async function syncAll({
|
|
|
675
777
|
}
|
|
676
778
|
targetState.skillScope = skillScope
|
|
677
779
|
targetState.syncMode = syncMode
|
|
678
|
-
if (
|
|
780
|
+
if (global) {
|
|
679
781
|
syncState.syncMode = syncMode
|
|
680
782
|
await saveSyncState(projectDir, syncState)
|
|
681
783
|
if (cliPlatforms.length) await saveSyncState(homeDir, targetState)
|
package/src/sync-codex.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { chalk } from './utils.js'
|
|
4
|
+
import { linkSkill, prepareSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
4
5
|
import {
|
|
5
6
|
extractDescription,
|
|
6
7
|
loadHookManifests,
|
|
@@ -186,15 +187,14 @@ export async function scanExistingCodexSkills(proj) {
|
|
|
186
187
|
const dir = path.join(proj, '.agents', 'skills')
|
|
187
188
|
if (!await fs.pathExists(dir)) return new Set()
|
|
188
189
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
189
|
-
return new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
|
|
190
|
+
return new Set(entries.filter(e => e.isDirectory() || e.isSymbolicLink()).map(e => e.name))
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
async function copySkillsFlat(skills, proj) {
|
|
193
194
|
const root = path.join(proj, '.agents', 'skills')
|
|
194
195
|
for (const s of skills) {
|
|
195
196
|
const dest = path.join(root, s.name)
|
|
196
|
-
await
|
|
197
|
-
await fs.copy(s.sourcePath, dest, { overwrite: true, filter: copyFilter(s.sourcePath) })
|
|
197
|
+
await linkSkill({ skill: s, dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
|
|
198
198
|
logAction(`.agents/skills/${s.name}`)
|
|
199
199
|
}
|
|
200
200
|
return skills.length
|
|
@@ -206,7 +206,12 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
|
|
|
206
206
|
if (selectedNames.has(name)) continue
|
|
207
207
|
// 项目独有(模板未提供过)的条目永不删除,保留用户自定义
|
|
208
208
|
if (!templateNames.has(name)) continue
|
|
209
|
-
|
|
209
|
+
const dest = path.join(proj, ...subdir, name)
|
|
210
|
+
if (subdir[1] === 'skills') {
|
|
211
|
+
await removeSkillEntry({ dest, backupRoot: path.join(proj, '.dwy', 'skill-backups') })
|
|
212
|
+
} else {
|
|
213
|
+
await fs.remove(dest)
|
|
214
|
+
}
|
|
210
215
|
logAction(`${subdir.join('/')}/${name}`, 'red', '×')
|
|
211
216
|
count++
|
|
212
217
|
}
|
|
@@ -345,6 +350,8 @@ export async function syncCodex({
|
|
|
345
350
|
projectDir,
|
|
346
351
|
global = false,
|
|
347
352
|
selected,
|
|
353
|
+
homeDir,
|
|
354
|
+
sharedSkills,
|
|
348
355
|
staleRemovals = {},
|
|
349
356
|
} = {}) {
|
|
350
357
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
@@ -378,7 +385,10 @@ export async function syncCodex({
|
|
|
378
385
|
let syncedCount = 0
|
|
379
386
|
let removedCount = 0
|
|
380
387
|
|
|
381
|
-
|
|
388
|
+
const skills = sharedSkills
|
|
389
|
+
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
390
|
+
: await prepareSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
391
|
+
syncedCount += await copySkillsFlat(skills, proj)
|
|
382
392
|
syncedCount += await copyCodexHooks(syncedSelection.hooks, proj)
|
|
383
393
|
syncedCount += await syncCodexHooksJson(sourceDir, proj, selectedHookNames, global)
|
|
384
394
|
|