@manohub/app-kit 0.2.1 → 0.2.4

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.
@@ -81,14 +81,14 @@
81
81
  ## 四、阶段 3 单文件循环
82
82
 
83
83
  ```bash
84
- node node_modules/@manohub/app-kit/lint/run-all.mjs --changed # 直到本文件 0 违规
84
+ pnpm exec appkit lint --changed # 直到本文件 0 违规
85
85
  pnpm exec vue-tsc --noEmit # 直到 0 错
86
86
  ```
87
87
 
88
88
  - 护栏每条违规自带 `file:line` + `correction.summary` / `.example` + `doc`(契约章节锚点):
89
89
  **照 `correction` 改,不要另想一套**。
90
90
  - 需要单独排查某条规则时才直接跑单条脚本:
91
- `node node_modules/@manohub/app-kit/lint/style-audit.mjs`(component / structure 同理)。
91
+ `pnpm exec appkit lint:style`(component / structure 同理)。
92
92
  - 样式类违规的处理次序:能删就删(迁移后不再需要的覆盖段)→ 需要保留的改成布局属性 →
93
93
  视觉语义改用组件的 `tone` / `shape` / `size`。
94
94
 
@@ -120,7 +120,7 @@ pnpm exec vue-tsc --noEmit # 直到 0 错
120
120
 
121
121
  | 项 | 命令 / 做法 | 通过标准 |
122
122
  |---|---|---|
123
- | 护栏 | `node node_modules/@manohub/app-kit/lint/run-all.mjs` | 三条全绿;改动文件为 0;总数不高于基线 |
123
+ | 护栏 | `pnpm exec appkit lint` | 三条全绿;改动文件为 0;总数不高于基线 |
124
124
  | 类型 | `pnpm exec vue-tsc --noEmit` | 0 错 |
125
125
  | 构建 | `pnpm build` | 成功;产物大小与阶段 0 量级一致(差异需能解释) |
126
126
  | 页面 | 浏览器逐个打开路由页与弹窗 | 渲染、交互、样式、弹层落点与改造前等价 |
@@ -163,5 +163,54 @@ export APPKIT_APP_DIR=apps/<x>
163
163
  | 缺口 | 影响 | 处理 |
164
164
  |---|---|---|
165
165
  | 结构护栏只扫 `.ts` / `.tsx`,不扫 `.vue` | 未纳入骨架的 `.vue` 页面不会报 `structure/no-shell` | 属已知范围,不要以为漏了;要覆盖需先有页面级豁免能力 |
166
- | 骨架层尚未提供的件 | 某些位置只能用现有件近似 | 走契约缺件处置流程,不在应用侧自绘;缺件清单见契约 §7 与包内 `AGENTS.md` 的「待建件」 |
166
+ | 骨架层尚未提供的件 | 某些位置只能用现有件近似 | 走契约缺件处置流程,不在应用侧自绘;缺件清单与替代口径见契约 §7.1 |
167
167
  | 底层组件库版本跃迁带来的视觉差异 | 个别间距/圆角可能与改造前有细微不同 | 先确认是否骨架层规范使然;确属缺陷则记为骨架层待修项,不在应用侧写覆盖样式 |
168
+
169
+ ---
170
+
171
+ ## 九、变体路径:只迁骨架(Shell-only)
172
+
173
+ 适用:应用这批**只想要页面骨架**(`AppShell` / `AppPanel` / `Split` 的结构、滚动归属、高度链),
174
+ 组件替换与样式收口留到下一批次。这是受支持的路径(靠 `CONTRACT.md` §8.1 的规则级豁免),
175
+ 但**有三条硬前置**,缺一条就会出现「迁完更丑」:
176
+
177
+ 1. **接样式三行**(`reset.css` → `styles.css` → `./app.css`)。`AppShell` 的高度链、页头规格、两侧分隔线
178
+ 全在包内 CSS 与 `--ui-*` 令牌上,只换组件不接样式等于把骨架挂空。
179
+ 2. **去掉自绘根容器的 `100vh` / `h-screen`**(结构护栏 S5):`AppShell` 用 `height: 100%` 撑宿主容器,
180
+ 外面套一个 `100vh` 的自绘壳会溢出。
181
+ 3. **拆旧页头 / 面板头**(结构护栏 S6):`*page-header*` / `*panel-head*` 这类类名要删,页头交给
182
+ `AppShell.Header`;留着旧页头再叠新页头会同时命中 S2(未传 title)与 S6。
183
+
184
+ 配置(`appkit-guardrails.config.json`):
185
+
186
+ ```jsonc
187
+ {
188
+ "apps": [{
189
+ "dir": ".", "name": "<app>", "prefixes": ["<你的前缀>"], "pending": false,
190
+ "waivedRules": [
191
+ { "rule": "api/*", "reason": "只迁骨架(Shell-only):组件替换在下一批次", "since": "<日期>" },
192
+ { "rule": "style/*", "reason": "同上:样式收口等骨架稳定后再做", "since": "<日期>" }
193
+ ]
194
+ }]
195
+ }
196
+ ```
197
+
198
+ 范围:阶段 2 的推荐顺序里**只做第 2 步**(页面骨架),第 1 步只取其中的样式三行与入口,第 3、4 步停手。
199
+
200
+ ### 里程碑 A 验收
201
+
202
+ | 项 | 命令 | 通过标准 |
203
+ |---|---|---|
204
+ | 结构 | `pnpm exec appkit lint:structure` | **0 error** —— 结构类没有豁免,这是本里程碑的硬指标 |
205
+ | 全量(含豁免) | `pnpm exec appkit lint` | 退出码 0;`api/*` / `style/*` 的违规标 `waived`,条数入基线供后续「只减不增」比对 |
206
+ | 收口预演 | `pnpm exec appkit lint --strict` | 记录剩余违规总数 —— 这就是下一批次的起点 |
207
+ | 类型 / 构建 | `pnpm exec vue-tsc --noEmit` / `pnpm build` | 0 错 / 成功 |
208
+ | 页面 | 浏览器逐个过路由页与弹窗 | 滚动归属、表体滚动 + 分页常驻、弹层落点与改造前等价 |
209
+
210
+ ### 下一批次怎么收
211
+
212
+ 按 `api/*` → `style/*` 的顺序**逐条撤销豁免**:撤销一条 → 跑一次该条规则 → 确认能归零就继续下一条
213
+ (撤不掉的说明还缺件,走 `CONTRACT.md` §7 建件,而不是回退豁免)。全部撤完跑 `--strict` 复核。
214
+ 报告里出现「以下豁免当前 0 条命中,可以撤销了」就是在提醒你做这件事。
215
+
216
+ 登记:在应用文档里写清里程碑 A 的日期、结构违规数(0)、`--strict` 下的剩余违规数与撤销计划。
@@ -1,222 +1,298 @@
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, realpathSync, 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
- /**
144
- * 是否「被直接执行」(而非被 `import`)。
145
- *
146
- * **必须比对 realpath**:pnpm 把包实体放在 `node_modules/.pnpm/<pkg>@<ver>/...`,
147
- * 消费方引用的是软链路径 `node_modules/@manohub/app-kit/...`;Node 解析 ESM 时会取真实路径,
148
- * 于是 `import.meta.url` 与 `process.argv[1]` 字面不等 —— 直接比较会让安装器**静默不执行**
149
- * (真实踩过:在 pnpm 消费方里跑安装器零输出、技能也没落盘,而这正是最主要的使用场景)。
150
- *
151
- * `realpath` 可注入,便于单测覆盖「软链」「路径不存在」两种输入。
152
- */
153
- export function isDirectRun(argv1, moduleUrl, realpath = (p) => realpathSync(p)) {
154
- if (!argv1) return false
155
- if (moduleUrl === pathToFileURL(argv1).href) return true
156
- try {
157
- return moduleUrl === pathToFileURL(realpath(argv1)).href
158
- } catch {
159
- return false
160
- }
161
- }
162
-
163
- export function usage() {
164
- return [
165
- '把 @manohub/app-kit 随包的 AI 技能安装到本工程的技能目录。',
166
- '',
167
- '用法:',
168
- ' node node_modules/@manohub/app-kit/skills/install.mjs [选项]',
169
- '',
170
- '选项:',
171
- ' --target <dir> 指定目标目录(缺省 <cwd>/.codebuddy/skills)',
172
- ' --also-claude 同时安装到 <cwd>/.claude/skills',
173
- ' --dry-run 只打印将要落盘的内容,不写文件系统',
174
- ' --help, -h 显示本帮助',
175
- '',
176
- `管理的技能:${SKILL_NAMES.join(' / ')}(安装时先清理同名目录再复制,可反复执行)`,
177
- ].join('\n')
178
- }
179
-
180
- function printSummary(results, plan, dryRun) {
181
- const lines = ['[app-kit 技能安装]', ` 源:${plan.sourceDir}`, ` 技能:${plan.skills.join('、')}`]
182
- for (const target of plan.targetDirs) {
183
- lines.push(` 目标:${target}`)
184
- }
185
- for (const item of results) {
186
- lines.push(` ${dryRun ? '将写入' : '已写入'} ${item.to}(${item.files} 个文件)`)
187
- }
188
- if (dryRun) {
189
- lines.push(' dry-run:未落盘,去掉 --dry-run 执行实际安装')
190
- } else {
191
- lines.push(' 完成:技能目录已与包内内容对齐(包升级后重跑本命令即刷新)')
192
- }
193
- console.log(lines.join('\n'))
194
- }
195
-
196
- async function main() {
197
- const opts = parseArgs(process.argv.slice(2))
198
- if (opts.help) {
199
- console.log(usage())
200
- return
201
- }
202
-
203
- const sourceDir = dirname(fileURLToPath(import.meta.url))
204
- const targetDirs = resolveTargets({ cwd: process.cwd(), target: opts.target, alsoClaude: opts.alsoClaude })
205
-
206
- let plan
207
- try {
208
- plan = planInstall({ sourceDir, targetDirs })
209
- } catch (error) {
210
- console.error(`[app-kit 技能安装] 失败:${error.message}`)
211
- process.exitCode = 1
212
- return
213
- }
214
-
215
- const results = applyInstall(plan, { dryRun: opts.dryRun })
216
- printSummary(results, plan, opts.dryRun)
217
- }
218
-
219
- // 仅在被直接执行时落盘;被测试 import 时只取纯函数
220
- if (isDirectRun(process.argv[1], import.meta.url)) {
221
- await main()
222
- }
1
+ /**
2
+ * 技能安装器:把随本包分发的 AI 技能落到消费方的技能目录。
3
+ *
4
+ * 用法(消费方工程根执行):
5
+ * pnpm exec appkit install
6
+ * pnpm exec appkit install --also-claude
7
+ * pnpm exec appkit install --target .x/skills
8
+ * pnpm exec appkit install --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
+ * 5. **落盘前先算差异**:告诉消费方「这次刷新了什么」——包升级后技能内容有没有变,光看命令输出
20
+ * 是看不出来的;没有差异就是「已是最新」,有差异则列出新增 / 更新 / 删除的文件数。
21
+ */
22
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs'
23
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
24
+ import { fileURLToPath, pathToFileURL } from 'node:url'
25
+
26
+ /** 本技能包管理的技能目录名(白名单:只有这些名字允许被创建或清理) */
27
+ export const SKILL_NAMES = ['app-kit', 'app-kit-migrate', 'app-kit-dev']
28
+
29
+ /** 缺省的技能目录(相对消费方工程根) */
30
+ export const TARGET_CODEXBUDDY = ['.codebuddy', 'skills']
31
+ export const TARGET_CLAUDE = ['.claude', 'skills']
32
+
33
+ /** 把源目录里「名字在白名单内、且含 SKILL.md」的目录识别为可安装技能 */
34
+ export function listSourceSkills(sourceDir) {
35
+ return SKILL_NAMES.filter((name) => {
36
+ const dir = join(sourceDir, name)
37
+ return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, 'SKILL.md'))
38
+ })
39
+ }
40
+
41
+ /** 目标目录安全校验:不允许写到文件系统根(`/`、`C:\`) */
42
+ export function assertSafeTarget(targetDir) {
43
+ const resolved = resolve(targetDir)
44
+ if (resolved === dirname(resolved)) {
45
+ throw new Error(`拒绝写入文件系统根目录:${resolved}`)
46
+ }
47
+ return resolved
48
+ }
49
+
50
+ /** 越界保护:`path` 必须是 `targetDir` 的直接子路径,且名字在白名单内 */
51
+ export function assertManagedPath(targetDir, path) {
52
+ const target = assertSafeTarget(targetDir)
53
+ const name = basename(path)
54
+ if (!SKILL_NAMES.includes(name)) {
55
+ throw new Error(`拒绝操作白名单外的目录:${path}`)
56
+ }
57
+ if (dirname(resolve(path)) !== target) {
58
+ throw new Error(`拒绝操作目标目录之外的路径:${path}`)
59
+ }
60
+ return join(target, name)
61
+ }
62
+
63
+ /**
64
+ * 解析命令行参数。未知参数直接报错 —— 静默忽略会让人以为选项生效了。
65
+ * (不参与单测的 IO,纯字符串处理)
66
+ */
67
+ export function parseArgs(argv) {
68
+ const opts = { target: null, alsoClaude: false, dryRun: false, help: false }
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const arg = argv[i]
71
+ if (arg === '--target') {
72
+ const value = argv[i + 1]
73
+ if (!value || value.startsWith('--')) throw new Error('--target 需要一个目录参数')
74
+ opts.target = value
75
+ i += 1
76
+ } else if (arg === '--also-claude') {
77
+ opts.alsoClaude = true
78
+ } else if (arg === '--dry-run') {
79
+ opts.dryRun = true
80
+ } else if (arg === '--help' || arg === '-h') {
81
+ opts.help = true
82
+ } else {
83
+ throw new Error(`未知参数:${arg}(用 --help 看用法)`)
84
+ }
85
+ }
86
+ return opts
87
+ }
88
+
89
+ /** 计算落盘目标目录:默认 `.codebuddy/skills`,`--target` 优先,`--also-claude` 追加 */
90
+ export function resolveTargets({ cwd, target = null, alsoClaude = false }) {
91
+ if (target) {
92
+ const resolved = isAbsolute(target) ? target : join(cwd, target)
93
+ return [assertSafeTarget(resolved)]
94
+ }
95
+ const targets = [assertSafeTarget(join(cwd, ...TARGET_CODEXBUDDY))]
96
+ if (alsoClaude) targets.push(assertSafeTarget(join(cwd, ...TARGET_CLAUDE)))
97
+ return targets
98
+ }
99
+
100
+ /** 统计目录下的文件数(用于落盘摘要) */
101
+ export function countFiles(dir) {
102
+ let total = 0
103
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
104
+ total += entry.isDirectory() ? countFiles(join(dir, entry.name)) : 1
105
+ }
106
+ return total
107
+ }
108
+
109
+ /** 递归列出目录下的文件(绝对路径) */
110
+ function walkFiles(dir) {
111
+ const out = []
112
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
113
+ const full = join(dir, entry.name)
114
+ if (entry.isDirectory()) out.push(...walkFiles(full))
115
+ else out.push(full)
116
+ }
117
+ return out
118
+ }
119
+
120
+ /**
121
+ * 读一份目录快照:`相对路径(posix)→ 文件内容`;目录不存在时返回 `null`
122
+ * (`null` 表示「环境里还没有这份技能」,与「有目录但内容全空」区分开)。
123
+ */
124
+ export function readDirSnapshot(dir) {
125
+ if (!existsSync(dir)) return null
126
+ const snapshot = new Map()
127
+ for (const file of walkFiles(dir)) {
128
+ snapshot.set(relative(dir, file).split('\\').join('/'), readFileSync(file, 'utf8'))
129
+ }
130
+ return snapshot
131
+ }
132
+
133
+ /**
134
+ * 比较「包内技能」与「已落盘技能」的内容差异,用于告诉消费方这次刷新了什么。
135
+ *
136
+ * 比"版本号比对"更直接:技能的版本号与包版本没有绑定关系,而内容一变就是要重跑的信号。
137
+ */
138
+ export function diffSkillDirs(from, to) {
139
+ const after = readDirSnapshot(from)
140
+ const before = readDirSnapshot(to)
141
+ if (after === null) throw new Error(`技能源目录不存在:${from}`)
142
+ if (before === null) {
143
+ return { firstInstall: true, added: [...after.keys()].sort(), changed: [], removed: [] }
144
+ }
145
+ const added = []
146
+ const changed = []
147
+ for (const [rel, content] of after) {
148
+ if (!before.has(rel)) added.push(rel)
149
+ else if (before.get(rel) !== content) changed.push(rel)
150
+ }
151
+ const removed = [...before.keys()].filter((rel) => !after.has(rel))
152
+ return { firstInstall: false, added: added.sort(), changed: changed.sort(), removed: removed.sort() }
153
+ }
154
+
155
+ /** 生成安装计划(不落盘);源目录里一个技能都没有时直接报错 */
156
+ export function planInstall({ sourceDir, targetDirs }) {
157
+ if (!existsSync(sourceDir)) throw new Error(`技能源目录不存在:${sourceDir}`)
158
+ const skills = listSourceSkills(sourceDir)
159
+ if (skills.length === 0) {
160
+ throw new Error(`技能源目录里没有可安装的技能(期望 ${SKILL_NAMES.join(' / ')}):${sourceDir}`)
161
+ }
162
+ if (targetDirs.length === 0) throw new Error('没有解析出任何目标目录')
163
+
164
+ const actions = []
165
+ for (const target of targetDirs) {
166
+ for (const skill of skills) {
167
+ const from = join(sourceDir, skill)
168
+ const to = join(assertSafeTarget(target), skill)
169
+ assertManagedPath(target, to)
170
+ actions.push({ skill, from, to, files: countFiles(from) })
171
+ }
172
+ }
173
+ return { sourceDir, targetDirs, skills, actions }
174
+ }
175
+
176
+ /**
177
+ * 落盘(`dryRun` 时只返回计划结果,不写文件系统)。
178
+ *
179
+ * 差异必须**在删除之前**读出来(删完就比对不出来了),所以先快照再清理。
180
+ */
181
+ export function applyInstall(plan, { dryRun = false } = {}) {
182
+ const results = []
183
+ for (const action of plan.actions) {
184
+ const diff = diffSkillDirs(action.from, action.to)
185
+ if (!dryRun) {
186
+ mkdirSync(dirname(action.to), { recursive: true })
187
+ // 先清理再复制:保证与包内内容完全一致(幂等)
188
+ rmSync(action.to, { recursive: true, force: true })
189
+ cpSync(action.from, action.to, { recursive: true })
190
+ }
191
+ results.push({ ...action, diff, dryRun })
192
+ }
193
+ return results
194
+ }
195
+
196
+ /**
197
+ * 是否「被直接执行」(而非被 `import`)。
198
+ *
199
+ * **必须比对 realpath**:pnpm 把包实体放在 `node_modules/.pnpm/<pkg>@<ver>/...`,
200
+ * 消费方引用的是软链路径 `node_modules/@manohub/app-kit/...`;Node 解析 ESM 时会取真实路径,
201
+ * 于是 `import.meta.url` 与 `process.argv[1]` 字面不等 —— 直接比较会让安装器**静默不执行**
202
+ * (真实踩过:在 pnpm 消费方里跑安装器零输出、技能也没落盘,而这正是最主要的使用场景)。
203
+ *
204
+ * `realpath` 可注入,便于单测覆盖「软链」「路径不存在」两种输入。
205
+ */
206
+ export function isDirectRun(argv1, moduleUrl, realpath = (p) => realpathSync(p)) {
207
+ if (!argv1) return false
208
+ if (moduleUrl === pathToFileURL(argv1).href) return true
209
+ try {
210
+ return moduleUrl === pathToFileURL(realpath(argv1)).href
211
+ } catch {
212
+ return false
213
+ }
214
+ }
215
+
216
+ export function usage() {
217
+ return [
218
+ '把 @manohub/app-kit 随包的 AI 技能安装到本工程的技能目录。',
219
+ '',
220
+ '用法:',
221
+ ' pnpm exec appkit install [选项] (npm 消费方:npx appkit install)',
222
+ ' node node_modules/@manohub/app-kit/skills/install.mjs [选项] 等价写法(老脚本/钩子里可用)',
223
+ '',
224
+ '选项:',
225
+ ' --target <dir> 指定目标目录(缺省 <cwd>/.codebuddy/skills)',
226
+ ' --also-claude 同时安装到 <cwd>/.claude/skills',
227
+ ' --dry-run 只打印将要落盘的内容,不写文件系统',
228
+ ' --help, -h 显示本帮助',
229
+ '',
230
+ `管理的技能:${SKILL_NAMES.join(' / ')}(安装时先清理同名目录再复制,可反复执行)`,
231
+ '每次执行都会报告技能内容有没有变化:显示「已是包内最新」说明不必重跑,',
232
+ '显示「更新 N」说明这次升级改了技能内容、本次刷新已生效。',
233
+ ].join('\n')
234
+ }
235
+
236
+ /** 把一次安装的差异描述成一句人话;无差异时明确说「已是最新」 */
237
+ export function describeDiff(diff, files = 0) {
238
+ if (!diff) return ''
239
+ if (diff.firstInstall) return `首次安装(${files} 个文件)`
240
+ const parts = []
241
+ if (diff.changed.length > 0) parts.push(`更新 ${diff.changed.length}`)
242
+ if (diff.added.length > 0) parts.push(`新增 ${diff.added.length}`)
243
+ if (diff.removed.length > 0) parts.push(`删除 ${diff.removed.length}`)
244
+ if (parts.length === 0) return `已是包内最新(内容无变化,共 ${files} 个文件)`
245
+ return `${parts.join(' / ')}(共 ${files} 个文件)`
246
+ }
247
+
248
+ /** 一次安装是否什么都没变(用于把「无需刷新」说清楚) */
249
+ function isUnchanged(diff) {
250
+ return Boolean(diff) && !diff.firstInstall && diff.changed.length + diff.added.length + diff.removed.length === 0
251
+ }
252
+
253
+ function printSummary(results, plan, dryRun) {
254
+ const lines = ['[app-kit 技能安装]', ` 源:${plan.sourceDir}`, ` 技能:${plan.skills.join('、')}`]
255
+ for (const target of plan.targetDirs) {
256
+ lines.push(` 目标:${target}`)
257
+ }
258
+ if (dryRun) lines.push(' dry-run:以下为将落盘的结果(未写文件系统)')
259
+ for (const item of results) {
260
+ lines.push(` ${item.to} —— ${describeDiff(item.diff, item.files)}`)
261
+ }
262
+ if (dryRun) {
263
+ lines.push(' 去掉 --dry-run 执行实际安装')
264
+ } else if (results.length > 0 && results.every((item) => isUnchanged(item.diff))) {
265
+ lines.push(' 完成:技能内容与包内一致,无需刷新(说明本包这次升级没有改动技能包)')
266
+ } else {
267
+ lines.push(' 完成:技能目录已与包内内容对齐(包升级后重跑本命令即刷新)')
268
+ }
269
+ console.log(lines.join('\n'))
270
+ }
271
+
272
+ async function main() {
273
+ const opts = parseArgs(process.argv.slice(2))
274
+ if (opts.help) {
275
+ console.log(usage())
276
+ return
277
+ }
278
+
279
+ const sourceDir = dirname(fileURLToPath(import.meta.url))
280
+ const targetDirs = resolveTargets({ cwd: process.cwd(), target: opts.target, alsoClaude: opts.alsoClaude })
281
+
282
+ let plan
283
+ try {
284
+ plan = planInstall({ sourceDir, targetDirs })
285
+ } catch (error) {
286
+ console.error(`[app-kit 技能安装] 失败:${error.message}`)
287
+ process.exitCode = 1
288
+ return
289
+ }
290
+
291
+ const results = applyInstall(plan, { dryRun: opts.dryRun })
292
+ printSummary(results, plan, opts.dryRun)
293
+ }
294
+
295
+ // 仅在被直接执行时落盘;被测试 import 时只取纯函数
296
+ if (isDirectRun(process.argv[1], import.meta.url)) {
297
+ await main()
298
+ }