@manohub/app-kit 0.1.0 → 0.2.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/README.md +14 -0
- package/package.json +4 -3
- package/skills/README.md +43 -0
- package/skills/app-kit/SKILL.md +65 -0
- package/skills/app-kit/references/adoption.md +139 -0
- package/skills/app-kit/references/contract-index.md +43 -0
- package/skills/app-kit-dev/SKILL.md +67 -0
- package/skills/app-kit-dev/references/page-recipes.md +240 -0
- package/skills/app-kit-dev/references/style-rules.md +63 -0
- package/skills/app-kit-migrate/SKILL.md +99 -0
- package/skills/app-kit-migrate/references/migration-map.md +139 -0
- package/skills/app-kit-migrate/references/migration-playbook.md +167 -0
- package/skills/install.mjs +203 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能安装器:把随本包分发的 AI 技能落到消费方的技能目录。
|
|
3
|
+
*
|
|
4
|
+
* 用法(消费方工程根执行):
|
|
5
|
+
* node node_modules/@manohub/app-kit/skills/install.mjs
|
|
6
|
+
* node node_modules/@manohub/app-kit/skills/install.mjs --also-claude
|
|
7
|
+
* node node_modules/@manohub/app-kit/skills/install.mjs --target .x/skills
|
|
8
|
+
* node node_modules/@manohub/app-kit/skills/install.mjs --dry-run
|
|
9
|
+
*
|
|
10
|
+
* 设计取舍:
|
|
11
|
+
*
|
|
12
|
+
* 1. **幂等**:每个技能「先删同名目录再整体复制」。包升级后重跑一次即刷新到新版内容,
|
|
13
|
+
* 不存在「旧文件残留」或「同名合并」的中间态。
|
|
14
|
+
* 2. **越界保护**:只允许操作 `SKILL_NAMES` 白名单内的目录名,且目标必须是白名单目录的**直接子路径**。
|
|
15
|
+
* 删除动作因此永远不可能落到用户自己的技能或其它文件上。
|
|
16
|
+
* 3. **零依赖 + 纯函数**:判定与计划部分抽成纯函数并由 `__tests__/skills-install.test.mjs`
|
|
17
|
+
* 做故障注入测试(一个对坏输入不报警的安装器等于没有安装器),落盘动作只在直接执行时发生。
|
|
18
|
+
* 4. 只复制**技能目录**,跳过本文件与 `README.md`(那是给包维护者看的),也不复制 `__tests__`。
|
|
19
|
+
*/
|
|
20
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
21
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
23
|
+
|
|
24
|
+
/** 本技能包管理的技能目录名(白名单:只有这些名字允许被创建或清理) */
|
|
25
|
+
export const SKILL_NAMES = ['app-kit', 'app-kit-migrate', 'app-kit-dev']
|
|
26
|
+
|
|
27
|
+
/** 缺省的技能目录(相对消费方工程根) */
|
|
28
|
+
export const TARGET_CODEXBUDDY = ['.codebuddy', 'skills']
|
|
29
|
+
export const TARGET_CLAUDE = ['.claude', 'skills']
|
|
30
|
+
|
|
31
|
+
/** 把源目录里「名字在白名单内、且含 SKILL.md」的目录识别为可安装技能 */
|
|
32
|
+
export function listSourceSkills(sourceDir) {
|
|
33
|
+
return SKILL_NAMES.filter((name) => {
|
|
34
|
+
const dir = join(sourceDir, name)
|
|
35
|
+
return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, 'SKILL.md'))
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 目标目录安全校验:不允许写到文件系统根(`/`、`C:\`) */
|
|
40
|
+
export function assertSafeTarget(targetDir) {
|
|
41
|
+
const resolved = resolve(targetDir)
|
|
42
|
+
if (resolved === dirname(resolved)) {
|
|
43
|
+
throw new Error(`拒绝写入文件系统根目录:${resolved}`)
|
|
44
|
+
}
|
|
45
|
+
return resolved
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 越界保护:`path` 必须是 `targetDir` 的直接子路径,且名字在白名单内 */
|
|
49
|
+
export function assertManagedPath(targetDir, path) {
|
|
50
|
+
const target = assertSafeTarget(targetDir)
|
|
51
|
+
const name = basename(path)
|
|
52
|
+
if (!SKILL_NAMES.includes(name)) {
|
|
53
|
+
throw new Error(`拒绝操作白名单外的目录:${path}`)
|
|
54
|
+
}
|
|
55
|
+
if (dirname(resolve(path)) !== target) {
|
|
56
|
+
throw new Error(`拒绝操作目标目录之外的路径:${path}`)
|
|
57
|
+
}
|
|
58
|
+
return join(target, name)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 解析命令行参数。未知参数直接报错 —— 静默忽略会让人以为选项生效了。
|
|
63
|
+
* (不参与单测的 IO,纯字符串处理)
|
|
64
|
+
*/
|
|
65
|
+
export function parseArgs(argv) {
|
|
66
|
+
const opts = { target: null, alsoClaude: false, dryRun: false, help: false }
|
|
67
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
68
|
+
const arg = argv[i]
|
|
69
|
+
if (arg === '--target') {
|
|
70
|
+
const value = argv[i + 1]
|
|
71
|
+
if (!value || value.startsWith('--')) throw new Error('--target 需要一个目录参数')
|
|
72
|
+
opts.target = value
|
|
73
|
+
i += 1
|
|
74
|
+
} else if (arg === '--also-claude') {
|
|
75
|
+
opts.alsoClaude = true
|
|
76
|
+
} else if (arg === '--dry-run') {
|
|
77
|
+
opts.dryRun = true
|
|
78
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
79
|
+
opts.help = true
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error(`未知参数:${arg}(用 --help 看用法)`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return opts
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 计算落盘目标目录:默认 `.codebuddy/skills`,`--target` 优先,`--also-claude` 追加 */
|
|
88
|
+
export function resolveTargets({ cwd, target = null, alsoClaude = false }) {
|
|
89
|
+
if (target) {
|
|
90
|
+
const resolved = isAbsolute(target) ? target : join(cwd, target)
|
|
91
|
+
return [assertSafeTarget(resolved)]
|
|
92
|
+
}
|
|
93
|
+
const targets = [assertSafeTarget(join(cwd, ...TARGET_CODEXBUDDY))]
|
|
94
|
+
if (alsoClaude) targets.push(assertSafeTarget(join(cwd, ...TARGET_CLAUDE)))
|
|
95
|
+
return targets
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 统计目录下的文件数(用于落盘摘要) */
|
|
99
|
+
export function countFiles(dir) {
|
|
100
|
+
let total = 0
|
|
101
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
total += entry.isDirectory() ? countFiles(join(dir, entry.name)) : 1
|
|
103
|
+
}
|
|
104
|
+
return total
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 生成安装计划(不落盘);源目录里一个技能都没有时直接报错 */
|
|
108
|
+
export function planInstall({ sourceDir, targetDirs }) {
|
|
109
|
+
if (!existsSync(sourceDir)) throw new Error(`技能源目录不存在:${sourceDir}`)
|
|
110
|
+
const skills = listSourceSkills(sourceDir)
|
|
111
|
+
if (skills.length === 0) {
|
|
112
|
+
throw new Error(`技能源目录里没有可安装的技能(期望 ${SKILL_NAMES.join(' / ')}):${sourceDir}`)
|
|
113
|
+
}
|
|
114
|
+
if (targetDirs.length === 0) throw new Error('没有解析出任何目标目录')
|
|
115
|
+
|
|
116
|
+
const actions = []
|
|
117
|
+
for (const target of targetDirs) {
|
|
118
|
+
for (const skill of skills) {
|
|
119
|
+
const from = join(sourceDir, skill)
|
|
120
|
+
const to = join(assertSafeTarget(target), skill)
|
|
121
|
+
assertManagedPath(target, to)
|
|
122
|
+
actions.push({ skill, from, to, files: countFiles(from) })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { sourceDir, targetDirs, skills, actions }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 落盘(`dryRun` 时只返回计划结果,不写文件系统) */
|
|
129
|
+
export function applyInstall(plan, { dryRun = false } = {}) {
|
|
130
|
+
const results = []
|
|
131
|
+
for (const action of plan.actions) {
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
mkdirSync(dirname(action.to), { recursive: true })
|
|
134
|
+
// 先清理再复制:保证与包内内容完全一致(幂等)
|
|
135
|
+
rmSync(action.to, { recursive: true, force: true })
|
|
136
|
+
cpSync(action.from, action.to, { recursive: true })
|
|
137
|
+
}
|
|
138
|
+
results.push({ ...action, dryRun })
|
|
139
|
+
}
|
|
140
|
+
return results
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function usage() {
|
|
144
|
+
return [
|
|
145
|
+
'把 @manohub/app-kit 随包的 AI 技能安装到本工程的技能目录。',
|
|
146
|
+
'',
|
|
147
|
+
'用法:',
|
|
148
|
+
' node node_modules/@manohub/app-kit/skills/install.mjs [选项]',
|
|
149
|
+
'',
|
|
150
|
+
'选项:',
|
|
151
|
+
' --target <dir> 指定目标目录(缺省 <cwd>/.codebuddy/skills)',
|
|
152
|
+
' --also-claude 同时安装到 <cwd>/.claude/skills',
|
|
153
|
+
' --dry-run 只打印将要落盘的内容,不写文件系统',
|
|
154
|
+
' --help, -h 显示本帮助',
|
|
155
|
+
'',
|
|
156
|
+
`管理的技能:${SKILL_NAMES.join(' / ')}(安装时先清理同名目录再复制,可反复执行)`,
|
|
157
|
+
].join('\n')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function printSummary(results, plan, dryRun) {
|
|
161
|
+
const lines = ['[app-kit 技能安装]', ` 源:${plan.sourceDir}`, ` 技能:${plan.skills.join('、')}`]
|
|
162
|
+
for (const target of plan.targetDirs) {
|
|
163
|
+
lines.push(` 目标:${target}`)
|
|
164
|
+
}
|
|
165
|
+
for (const item of results) {
|
|
166
|
+
lines.push(` ${dryRun ? '将写入' : '已写入'} ${item.to}(${item.files} 个文件)`)
|
|
167
|
+
}
|
|
168
|
+
if (dryRun) {
|
|
169
|
+
lines.push(' dry-run:未落盘,去掉 --dry-run 执行实际安装')
|
|
170
|
+
} else {
|
|
171
|
+
lines.push(' 完成:技能目录已与包内内容对齐(包升级后重跑本命令即刷新)')
|
|
172
|
+
}
|
|
173
|
+
console.log(lines.join('\n'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function main() {
|
|
177
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
178
|
+
if (opts.help) {
|
|
179
|
+
console.log(usage())
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sourceDir = dirname(fileURLToPath(import.meta.url))
|
|
184
|
+
const targetDirs = resolveTargets({ cwd: process.cwd(), target: opts.target, alsoClaude: opts.alsoClaude })
|
|
185
|
+
|
|
186
|
+
let plan
|
|
187
|
+
try {
|
|
188
|
+
plan = planInstall({ sourceDir, targetDirs })
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error(`[app-kit 技能安装] 失败:${error.message}`)
|
|
191
|
+
process.exitCode = 1
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const results = applyInstall(plan, { dryRun: opts.dryRun })
|
|
196
|
+
printSummary(results, plan, opts.dryRun)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 仅在被直接执行时落盘;被测试 import 时只取纯函数
|
|
200
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
201
|
+
if (isDirectRun) {
|
|
202
|
+
await main()
|
|
203
|
+
}
|