create-dwy 0.21.0 → 0.23.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 +24 -0
- package/bin/index.js +6 -3
- package/package.json +1 -1
- package/src/cli-help.js +29 -13
- package/src/self-upgrade.js +7 -0
- package/src/skill-store.js +197 -0
- package/src/skills-install.js +47 -11
- package/src/sync-all.js +216 -605
- package/src/sync-codex.js +48 -25
- package/src/sync-global-skills.js +64 -14
- package/src/sync-opencode.js +9 -5
- package/src/sync-plan.js +278 -0
- package/src/sync.js +33 -18
- package/src/tab-multiselect.js +7 -5
- 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,29 @@
|
|
|
1
1
|
# create-dwy
|
|
2
2
|
|
|
3
|
+
## [0.23.0] - 2026-11-02
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 同步改为「范围 → 平台 → 本次处理内容 → 逐项选择 → 清单确认」。默认只选 Skills;项目 Rules、全局通用规范和 Hooks 必须显式选择,未选择的类型及其记录保持原样。全局不提供 Cursor。
|
|
8
|
+
- 默认勾选只读取当前范围及所选平台。项目 Skills 全部适用平台已有有效全局入口时显示“全局已启用”并禁用重复勾选;部分平台覆盖时只为缺少入口的平台安装。
|
|
9
|
+
- 确认页和执行共用操作清单,列出共享副本、入口、旧项清理、配置和同步记录。项目去重只备份或移除项目入口,不改写全局提供者;用户主目录及其别名不能作为项目范围同步。
|
|
10
|
+
- `sync` / `migrate` 只准备缺失或损坏的共享副本,已有内容由 `refresh` / `upgrade` 刷新。产品流程的外部依赖补装也纳入确认清单,只拉取缺失内容并保留已有副本。
|
|
11
|
+
- 全局 Codex Hooks 遵循自定义 `CODEX_HOME`,命令路径正确引用空格和单引号;同步保留个人配置,补充平台兼容、取消无写入和真实终端交互回归。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [0.22.0] - 2026-11-02
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- Skills 改为用户目录 `.dwy/skills/<name>` 内的一份共享副本,项目和全局入口使用整个目录的软链接;同平台已有有效全局入口时清理项目重复入口。原真实目录先备份,自定义 Skills、Rules 和 Hooks 保留。
|
|
20
|
+
- 新增 `dwy migrate`,在老项目根目录一键迁移已有托管 Skills,无需重新勾选;重复执行不重复备份,已移除的历史模板和已有整个 Skills 根目录链接保留并报告。
|
|
21
|
+
- 新增 `dwy refresh`,使用当前 CLI 包内模板刷新所有已安装的共享副本;`dwy upgrade` 升级成功后自动调用新进程刷新,所有已迁移项目无需分别更新。
|
|
22
|
+
- 共享副本刷新加入互斥及整批失败恢复;扫描和清理统一保护目录链接目标,防止自链和穿透根链接误删文件。全局默认选择覆盖自定义 `CODEX_HOME`。
|
|
23
|
+
- CHANGELOG 规则明确要求每个版本条目包含版本号和日期,禁止 `Unreleased` 等占位标题,与是否发布无关;发布记录提取兼容带日期和方括号的版本标题。
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
3
27
|
## 0.21.0
|
|
4
28
|
|
|
5
29
|
### 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
|
@@ -14,29 +14,36 @@ export function buildHelpText(version) {
|
|
|
14
14
|
`dwy ${version}`,
|
|
15
15
|
'',
|
|
16
16
|
'用法:',
|
|
17
|
-
' dwy
|
|
17
|
+
' dwy 选择范围、平台和处理内容,预览清单后同步',
|
|
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
|
'',
|
|
23
25
|
'同步步骤:',
|
|
24
|
-
' 1.
|
|
25
|
-
' 2.
|
|
26
|
-
' 3.
|
|
27
|
-
'
|
|
28
|
-
'
|
|
26
|
+
' 1. 选择同步范围:当前项目或全局。',
|
|
27
|
+
' 2. 选择该范围支持的平台;全局不提供 Cursor。',
|
|
28
|
+
' 3. 选择本次处理内容,默认只选 Skills;未选择的类型保持原样。',
|
|
29
|
+
' 项目:Skills、项目 Rules、Hooks;全局:Skills、全局通用规范、Hooks。',
|
|
30
|
+
' 内容选项按平台能力过滤。Hooks 选中类型后按平台自动处理。',
|
|
31
|
+
' 4. 逐项选择 Skills / 项目 Rules;全局通用规范显示摘要。',
|
|
32
|
+
' 5. 查看写入、备份和清理清单,确认后执行;取消不写文件。',
|
|
33
|
+
' Commands 不参与同步。',
|
|
29
34
|
'',
|
|
30
35
|
'交互按键:',
|
|
31
36
|
' Skills / Rules:←/→ 切分类,↑/↓ 移动,Space 勾选,a 全选/清空当前分类。',
|
|
32
|
-
'
|
|
37
|
+
' 范围 / 平台 / 内容:↑/↓ 移动;多选用 Space 勾选,可输入关键字筛选。',
|
|
33
38
|
' Enter 确认当前步骤,Esc 或 Ctrl+C 取消。',
|
|
34
39
|
'',
|
|
35
40
|
'常见用法:',
|
|
36
|
-
' 同步当前项目:cd your-project 后执行 dwy
|
|
37
|
-
'
|
|
41
|
+
' 同步当前项目:cd your-project 后执行 dwy,选择「当前项目」及本次处理内容。',
|
|
42
|
+
' 同步全局:执行 dwy,选择「全局」及 Skills、全局通用规范或 Hooks。',
|
|
38
43
|
' 更新或调整配置:再次执行 dwy sync,检查勾选项,再确认同步。',
|
|
39
44
|
' 更新 CLI:dwy upgrade(全局安装时可用;更新后执行 dwy --version 查看版本)。',
|
|
45
|
+
' 迁移老项目:cd your-project 后执行 dwy migrate,无需重新勾选。',
|
|
46
|
+
' 手动升级包后:执行一次 dwy refresh,所有已迁移的链接同时生效。',
|
|
40
47
|
' 首次安装:npm install -g create-dwy@latest',
|
|
41
48
|
'',
|
|
42
49
|
'平台支持:',
|
|
@@ -45,9 +52,18 @@ export function buildHelpText(version) {
|
|
|
45
52
|
' OpenCode:全局 ~/.config/opencode/AGENTS.md;项目根 AGENTS.md;当前不支持 Hooks。',
|
|
46
53
|
' Cursor:仅项目 Rules;全局规则在 Cursor 设置中管理。',
|
|
47
54
|
'',
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
55
|
+
'同步仅处理当前范围、所选平台和内容;未选择的类型及其记录保持原样。',
|
|
56
|
+
'用户主目录不能作为项目范围同步,请选择全局或进入项目目录。',
|
|
57
|
+
'取消已有勾选会在确认后移除当前范围的托管入口;不勾选整个类型则不处理。',
|
|
58
|
+
'Skills 副本集中在 ~/.dwy/skills;项目和全局使用目录链接,同平台全局入口优先。',
|
|
59
|
+
'项目 Skills 全部适用平台均有全局入口时显示「全局已启用」,不能重复勾选。',
|
|
60
|
+
'部分平台已有全局入口时仍可选,只为缺少全局入口的平台安装项目链接。',
|
|
61
|
+
'项目同步只清理项目重复入口,不改全局提供者;真实目录先备份。',
|
|
62
|
+
'sync / migrate 仅准备缺失或损坏副本,不刷新已有共享内容;refresh / upgrade 才刷新。',
|
|
63
|
+
'产品流程缺失的外部依赖也列入清单,确认后联网补齐,已有内容保留。',
|
|
64
|
+
'旧真实目录备份到同步根的 .dwy/skill-backups;个人 Skills、Rules、Hooks 不参与 migrate。',
|
|
65
|
+
'旧项目首次需执行 dwy migrate;迁移后无需逐项目刷新 Skills。',
|
|
66
|
+
'Rules / Hooks 的模板更新仍需执行 dwy sync;已有会话缓存的 Skill 可能需要重新加载。',
|
|
51
67
|
'',
|
|
52
68
|
].join('\n')
|
|
53
69
|
}
|
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,197 @@
|
|
|
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
|
+
/** 仅为缺失或损坏的 Skill 准备共享副本;已有有效内容只登记,不随入口同步刷新。 */
|
|
24
|
+
export async function ensureSharedSkills({ skills, homeDir = os.homedir() }) {
|
|
25
|
+
const sources = []
|
|
26
|
+
for (const skill of skills) {
|
|
27
|
+
validateName(skill.name)
|
|
28
|
+
const cached = path.join(homeDir, '.dwy', 'skills', skill.name)
|
|
29
|
+
const stat = await fs.stat(path.join(cached, 'SKILL.md')).catch(error => {
|
|
30
|
+
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
|
|
31
|
+
throw error
|
|
32
|
+
})
|
|
33
|
+
sources.push(stat?.isFile() ? { ...skill, sourcePath: cached } : skill)
|
|
34
|
+
}
|
|
35
|
+
return prepareSharedSkills({ skills: sources, homeDir })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 将包内 Skills 准备到稳定用户目录,返回指向共享副本的条目。
|
|
40
|
+
* 同一用户的刷新互斥;完整副本准备失败时不替换旧内容。外部 Skills 缓存不纳入本模块清单。
|
|
41
|
+
*/
|
|
42
|
+
export async function prepareSharedSkills({ skills, homeDir = os.homedir() }) {
|
|
43
|
+
if (skills.length === 0) return []
|
|
44
|
+
for (const skill of skills) {
|
|
45
|
+
validateName(skill.name)
|
|
46
|
+
if (!(await fs.stat(path.join(skill.sourcePath, 'SKILL.md'))).isFile()) {
|
|
47
|
+
throw new Error(`Skill 缺少有效 SKILL.md:${skill.sourcePath}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const root = path.join(homeDir, '.dwy')
|
|
51
|
+
const store = path.join(root, 'skills')
|
|
52
|
+
await fs.ensureDir(store)
|
|
53
|
+
const lockPath = path.join(root, 'skill-store.lock')
|
|
54
|
+
let lock
|
|
55
|
+
try {
|
|
56
|
+
lock = await fs.open(lockPath, 'wx')
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.code === 'EEXIST') throw new Error(`另一个进程正在刷新 Skills;若前次异常退出,请移除 ${lockPath} 后重试`)
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
let staging
|
|
62
|
+
let preserveStaging = false
|
|
63
|
+
try {
|
|
64
|
+
staging = await fs.mkdtemp(path.join(root, '.skills-'))
|
|
65
|
+
const installed = new Set(await readInstalledNames(homeDir))
|
|
66
|
+
const prepared = []
|
|
67
|
+
const replacements = []
|
|
68
|
+
for (const skill of skills) {
|
|
69
|
+
const dest = path.join(store, skill.name)
|
|
70
|
+
const next = path.join(staging, skill.name)
|
|
71
|
+
if (path.resolve(skill.sourcePath) !== path.resolve(dest)) {
|
|
72
|
+
await fs.copy(skill.sourcePath, next, { dereference: true, filter: copyFilter(skill.sourcePath) })
|
|
73
|
+
replacements.push({ dest, next, old: path.join(staging, `${skill.name}.old`), saved: false, replaced: false })
|
|
74
|
+
}
|
|
75
|
+
installed.add(skill.name)
|
|
76
|
+
prepared.push({ ...skill, sourcePath: dest })
|
|
77
|
+
}
|
|
78
|
+
const manifest = path.join(staging, 'skill-store.json')
|
|
79
|
+
await fs.writeJson(manifest, [...installed].sort(), { spaces: 2 })
|
|
80
|
+
try {
|
|
81
|
+
for (const entry of replacements) {
|
|
82
|
+
const existing = await fs.lstat(entry.dest).catch(error => {
|
|
83
|
+
if (error.code === 'ENOENT') return null
|
|
84
|
+
throw error
|
|
85
|
+
})
|
|
86
|
+
if (existing) {
|
|
87
|
+
await fs.rename(entry.dest, entry.old)
|
|
88
|
+
entry.saved = true
|
|
89
|
+
}
|
|
90
|
+
await fs.rename(entry.next, entry.dest)
|
|
91
|
+
entry.replaced = true
|
|
92
|
+
}
|
|
93
|
+
await fs.rename(manifest, path.join(root, 'skill-store.json'))
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const errors = [error]
|
|
96
|
+
for (const entry of [...replacements].reverse()) {
|
|
97
|
+
try {
|
|
98
|
+
if (entry.replaced) await fs.remove(entry.dest)
|
|
99
|
+
if (entry.saved) await fs.rename(entry.old, entry.dest)
|
|
100
|
+
} catch (restoreError) {
|
|
101
|
+
errors.push(restoreError)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (errors.length > 1) {
|
|
105
|
+
preserveStaging = true
|
|
106
|
+
throw new AggregateError(errors, `刷新失败且自动恢复未完成,旧副本保留在 ${staging}`)
|
|
107
|
+
}
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
return prepared
|
|
111
|
+
} finally {
|
|
112
|
+
try {
|
|
113
|
+
if (staging && !preserveStaging) await fs.remove(staging)
|
|
114
|
+
} finally {
|
|
115
|
+
await fs.close(lock)
|
|
116
|
+
await fs.remove(lockPath)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function entryParent(dest, backupRoot) {
|
|
122
|
+
const parent = await fs.realpath(path.dirname(dest))
|
|
123
|
+
const boundary = path.resolve(backupRoot, '..', '..')
|
|
124
|
+
const realBoundary = await fs.realpath(boundary)
|
|
125
|
+
const expected = path.join(realBoundary, path.relative(boundary, path.dirname(dest)))
|
|
126
|
+
if (parent !== expected) throw new Error(`Skills 根目录链接指向外部位置,保留原入口,不修改目标:${path.dirname(dest)}`)
|
|
127
|
+
return parent
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 清理托管入口:真实目录移到备份根,软链接仅移除入口,不遍历其目标。
|
|
132
|
+
* 返回真实目录的备份路径;入口不存在或原为链接时返回 null。
|
|
133
|
+
*/
|
|
134
|
+
export async function removeSkillEntry({ dest, backupRoot }) {
|
|
135
|
+
const stat = await fs.lstat(dest).catch(error => {
|
|
136
|
+
if (error.code === 'ENOENT') return null
|
|
137
|
+
throw error
|
|
138
|
+
})
|
|
139
|
+
if (!stat) return null
|
|
140
|
+
await entryParent(dest, backupRoot)
|
|
141
|
+
if (stat.isSymbolicLink()) {
|
|
142
|
+
await fs.unlink(dest)
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
await fs.ensureDir(backupRoot)
|
|
146
|
+
const backup = path.join(backupRoot, `${path.basename(dest)}-${randomUUID()}`)
|
|
147
|
+
await fs.rename(dest, backup)
|
|
148
|
+
console.log(chalk.gray(`已备份 ${dest} → ${backup}`))
|
|
149
|
+
return backup
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 将整个 Skill 目录链接到共享副本;原真实目录先备份,链接失败时恢复原入口。 */
|
|
153
|
+
export async function linkSkill({ skill, dest, backupRoot }) {
|
|
154
|
+
if (!(await fs.stat(path.join(skill.sourcePath, 'SKILL.md'))).isFile()) {
|
|
155
|
+
throw new Error(`Skill 缺少有效 SKILL.md:${skill.sourcePath}`)
|
|
156
|
+
}
|
|
157
|
+
await fs.ensureDir(path.dirname(dest))
|
|
158
|
+
const actualParent = await fs.realpath(path.dirname(dest))
|
|
159
|
+
// 整个全局 skills 根可能已链接到共享目录;此时入口就是源目录,不能把它再改为自链。
|
|
160
|
+
if (path.join(actualParent, path.basename(dest)) === await fs.realpath(skill.sourcePath)) return null
|
|
161
|
+
await entryParent(dest, backupRoot)
|
|
162
|
+
const stat = await fs.lstat(dest).catch(error => {
|
|
163
|
+
if (error.code === 'ENOENT') return null
|
|
164
|
+
throw error
|
|
165
|
+
})
|
|
166
|
+
const previousLink = stat?.isSymbolicLink() ? await fs.readlink(dest) : null
|
|
167
|
+
if (previousLink && path.resolve(path.dirname(dest), previousLink) === path.resolve(skill.sourcePath)) return null
|
|
168
|
+
const temporary = path.join(path.dirname(dest), `.${path.basename(dest)}.link-${randomUUID()}`)
|
|
169
|
+
await fs.symlink(path.resolve(skill.sourcePath), temporary, process.platform === 'win32' ? 'junction' : 'dir')
|
|
170
|
+
let backup
|
|
171
|
+
try {
|
|
172
|
+
backup = await removeSkillEntry({ dest, backupRoot })
|
|
173
|
+
try {
|
|
174
|
+
await fs.rename(temporary, dest)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (backup) await fs.rename(backup, dest)
|
|
177
|
+
else if (previousLink) await fs.symlink(previousLink, dest, process.platform === 'win32' ? 'junction' : 'dir')
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
return backup
|
|
181
|
+
} finally {
|
|
182
|
+
await fs.remove(temporary)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 使用当前 CLI 包内模板刷新已有共享副本;新包移除的条目保留,避免其他项目坏链。 */
|
|
187
|
+
export async function refreshSharedSkills({ sourceDir, homeDir = os.homedir() } = {}) {
|
|
188
|
+
const names = await readInstalledNames(homeDir)
|
|
189
|
+
const scans = await scanSkills(sourceDir || await resolveSourceDir())
|
|
190
|
+
const available = new Map(scans.map(skill => [skill.name, skill]))
|
|
191
|
+
const updated = names.filter(name => available.has(name))
|
|
192
|
+
const retained = names.filter(name => !available.has(name))
|
|
193
|
+
await prepareSharedSkills({ skills: updated.map(name => available.get(name)), homeDir })
|
|
194
|
+
for (const name of retained) console.log(chalk.yellow(`当前 CLI 已移除 ${name},保留共享旧副本以免其他项目链接失效。`))
|
|
195
|
+
console.log(chalk.green(`已刷新 ${updated.length} 个共享 Skills,保留 ${retained.length} 个旧副本。`))
|
|
196
|
+
return { updated, retained }
|
|
197
|
+
}
|
package/src/skills-install.js
CHANGED
|
@@ -71,24 +71,54 @@ async function shallowCloneTag(repoUrl, tag, destDir) {
|
|
|
71
71
|
await runGit(['clone', '--depth', '1', '--branch', tag, repoUrl, destDir])
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
async function validExternalSkill(dest) {
|
|
75
|
+
const stat = await fs.stat(path.join(dest, 'SKILL.md')).catch(error => {
|
|
76
|
+
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
|
|
77
|
+
throw error
|
|
78
|
+
})
|
|
79
|
+
return Boolean(stat?.isFile())
|
|
80
|
+
}
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
async function missingExternalSources(targetDir) {
|
|
83
|
+
const sources = []
|
|
84
|
+
for (const [name, source] of Object.entries(SKILL_SOURCES)) {
|
|
85
|
+
const skills = {}
|
|
86
|
+
for (const [skill, repoPath] of Object.entries(source.skills)) {
|
|
87
|
+
if (!await validExternalSkill(path.join(targetDir, skill))) skills[skill] = repoPath
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(skills).length) sources.push([name, { ...source, skills }])
|
|
90
|
+
}
|
|
91
|
+
return sources
|
|
92
|
+
}
|
|
81
93
|
|
|
82
|
-
|
|
94
|
+
/** 只读列出产品流程缺失的外部依赖;确认后再联网补齐,已有副本不刷新。 */
|
|
95
|
+
export async function planMissingExternalSkills(targetDir) {
|
|
96
|
+
const sources = await missingExternalSources(targetDir)
|
|
97
|
+
const paths = sources.flatMap(([, source]) => Object.keys(source.skills).map(name => path.join(targetDir, name)))
|
|
98
|
+
for (const [, source] of sources) {
|
|
99
|
+
const license = path.join(targetDir, source.licenseDest)
|
|
100
|
+
if (!await fs.pathExists(license)) paths.push(license)
|
|
101
|
+
}
|
|
102
|
+
if (sources.length) paths.push(path.join(targetDir, 'VERSIONS.json'))
|
|
103
|
+
return { paths, run: () => installSkillSources(sources, targetDir, true) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function installSkillSources(sources, targetDir, missingOnly) {
|
|
107
|
+
if (sources.length === 0) return
|
|
108
|
+
const versionsFile = path.join(targetDir, 'VERSIONS.json')
|
|
109
|
+
const previous = missingOnly && await fs.pathExists(versionsFile) ? await fs.readJson(versionsFile) : {}
|
|
110
|
+
const versions = { ...previous.versions }
|
|
111
|
+
await fs.ensureDir(targetDir)
|
|
112
|
+
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'dwy-skills-'))
|
|
83
113
|
try {
|
|
84
|
-
for (const [sourceName, src] of
|
|
114
|
+
for (const [sourceName, src] of sources) {
|
|
85
115
|
console.log(chalk.gray(`拉取 ${sourceName} @ ${src.tag} ...`))
|
|
86
116
|
const repoDir = path.join(tmpRoot, sourceName)
|
|
87
117
|
await shallowCloneTag(src.repo, src.tag, repoDir)
|
|
88
118
|
|
|
89
119
|
// 搬 LICENSE(MIT 要求保留原版权声明)
|
|
90
120
|
const licenseSrc = path.join(repoDir, src.licenseFile)
|
|
91
|
-
if (await fs.pathExists(licenseSrc)) {
|
|
121
|
+
if (await fs.pathExists(licenseSrc) && (!missingOnly || !await fs.pathExists(path.join(targetDir, src.licenseDest)))) {
|
|
92
122
|
await fs.copy(licenseSrc, path.join(targetDir, src.licenseDest), { overwrite: true })
|
|
93
123
|
}
|
|
94
124
|
|
|
@@ -96,6 +126,7 @@ export async function installSkills() {
|
|
|
96
126
|
for (const [skillName, repoPath] of Object.entries(src.skills)) {
|
|
97
127
|
const from = path.join(repoDir, repoPath)
|
|
98
128
|
const to = path.join(targetDir, skillName)
|
|
129
|
+
if (missingOnly && await validExternalSkill(to)) continue
|
|
99
130
|
if (!await fs.pathExists(from)) {
|
|
100
131
|
throw new Error(`${sourceName} @ ${src.tag} 内未找到 ${repoPath}(仓库结构可能变了)`)
|
|
101
132
|
}
|
|
@@ -109,8 +140,8 @@ export async function installSkills() {
|
|
|
109
140
|
// 写版本清单 + 清单指纹,供查看与后续更新比对。
|
|
110
141
|
// manifest_hash 变化(cli 加了 skill / 改了 tag)→ 下次 ensureSkillsInstalled 触发重装。
|
|
111
142
|
await fs.writeJson(
|
|
112
|
-
|
|
113
|
-
{ manifest_hash: computeManifestHash(), versions },
|
|
143
|
+
versionsFile,
|
|
144
|
+
{ manifest_hash: missingOnly ? previous.manifest_hash : computeManifestHash(), versions },
|
|
114
145
|
{ spaces: 2 },
|
|
115
146
|
)
|
|
116
147
|
console.log(chalk.green(`\n✓ 外部 skill 已安装到 ${targetDir}`))
|
|
@@ -119,6 +150,11 @@ export async function installSkills() {
|
|
|
119
150
|
}
|
|
120
151
|
}
|
|
121
152
|
|
|
153
|
+
/** 兼容显式安装调用:更新固定清单中的全部外部 Skills。 */
|
|
154
|
+
export async function installSkills() {
|
|
155
|
+
return installSkillSources(Object.entries(SKILL_SOURCES), getGlobalSkillsDir(), false)
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
// 全局 skill 是否已就绪(VERSIONS.json 存在)。
|
|
123
159
|
export async function skillsInstalled() {
|
|
124
160
|
return fs.pathExists(path.join(getGlobalSkillsDir(), 'VERSIONS.json'))
|