@andyqiu/codeforge 0.5.29 → 0.6.1

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/install.mjs ADDED
@@ -0,0 +1,754 @@
1
+ #!/usr/bin/env node
2
+ // ADR:unify-install-to-node-mjs
3
+ // ============================================================================
4
+ // CodeForge install.mjs — 零侵入安装到 opencode(Node ESM 零依赖,跨平台)
5
+ //
6
+ // 合并自原 install.sh(bash)+ install.ps1(PowerShell),一份逻辑跨平台。
7
+ //
8
+ // 输出三档:
9
+ // - quiet(默认):成功只打摘要块(≤5 行):版本+路径行、warn 行(命中时)、生效提示行
10
+ // - verbose(--verbose):打印每 Step / 每文件 / bytes / 自检每项 / 验证清单
11
+ // - error(永远):任何非零退出始终打完整错误上下文,不受 verbose 影响
12
+ //
13
+ // 用法:
14
+ // node install.mjs # 项目级(默认)
15
+ // node install.mjs --global # 全局
16
+ // node install.mjs --uninstall # 卸载
17
+ // node install.mjs --dry-run # 仅打印操作,不执行
18
+ // node install.mjs --skip-build # 跳过 npm run build
19
+ // node install.mjs --verbose # 展开全部过程输出
20
+ // ============================================================================
21
+
22
+ import * as fs from "node:fs"
23
+ import * as path from "node:path"
24
+ import * as os from "node:os"
25
+ import { spawnSync } from "node:child_process"
26
+ import { pathToFileURL, fileURLToPath } from "node:url"
27
+
28
+ const __filename = fileURLToPath(import.meta.url)
29
+ const __dirname = path.dirname(__filename)
30
+ const SOURCE_ROOT = __dirname
31
+ const IS_WIN = process.platform === "win32"
32
+
33
+ // ────────────────────────────────────────────────────────────────────
34
+ // A. 输出基础设施
35
+ // ────────────────────────────────────────────────────────────────────
36
+ const isTTY = process.stdout.isTTY
37
+ const C = isTTY
38
+ ? { reset: "\x1b[0m", bold: "\x1b[1m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m" }
39
+ : { reset: "", bold: "", red: "", green: "", yellow: "", blue: "", cyan: "" }
40
+
41
+ let VERBOSE = false
42
+ const summaryWarns = []
43
+
44
+ // verbose:即时打印(带 [codeforge] 前缀);quiet:静默
45
+ function vlog(...parts) {
46
+ if (VERBOSE) console.log(`${C.cyan}[codeforge]${C.reset} ${parts.join(" ")}`)
47
+ }
48
+ // verbose 即时 ok 行
49
+ function vok(...parts) {
50
+ if (VERBOSE) console.log(`${C.green}✓${C.reset} ${parts.join(" ")}`)
51
+ }
52
+ // 永远打 stderr,不受 verbose 影响
53
+ function err(...parts) {
54
+ console.error(`${C.red}✗${C.reset} ${parts.join(" ")}`)
55
+ }
56
+ // warn:verbose 时即时打;否则延迟到 printSummary
57
+ function addWarn(msg) {
58
+ if (VERBOSE) {
59
+ console.log(`${C.yellow}⚠${C.reset} ${msg}`)
60
+ } else {
61
+ summaryWarns.push(msg)
62
+ }
63
+ }
64
+
65
+ function shortenHome(p) {
66
+ const home = os.homedir()
67
+ if (home && p.startsWith(home)) return "~" + p.slice(home.length)
68
+ return p
69
+ }
70
+
71
+ // 打摘要块:版本+路径+✓ 行 → 遍历 warns → 末行生效提示
72
+ function printSummary({ version, targetRoot, dryRun }) {
73
+ if (VERBOSE) {
74
+ // verbose 模式 warn 已即时打过,这里只打最终一行收尾
75
+ for (const w of summaryWarns) console.log(`${C.yellow}⚠${C.reset} ${w}`)
76
+ }
77
+ const check = `${C.green}✓${C.reset}`
78
+ console.log(`CodeForge v${version} → ${shortenHome(targetRoot)} ${check}`)
79
+ if (dryRun) console.log(`${C.blue}[dry-run]${C.reset} 不会写任何文件`)
80
+ if (!VERBOSE) {
81
+ for (const w of summaryWarns) console.log(`${C.yellow}⚠${C.reset} ${w}`)
82
+ }
83
+ console.log(`重启 opencode 后生效`)
84
+ }
85
+
86
+ // dry-run 守卫:dry-run 时 vlog 描述不执行
87
+ function run(desc, fn) {
88
+ if (DRY_RUN) {
89
+ vlog(`[dry-run] ${desc}`)
90
+ return
91
+ }
92
+ fn()
93
+ }
94
+
95
+ // ────────────────────────────────────────────────────────────────────
96
+ // 参数解析
97
+ // ────────────────────────────────────────────────────────────────────
98
+ let DRY_RUN = false
99
+
100
+ function parseArgs(argv) {
101
+ const out = {
102
+ mode: "project",
103
+ action: "install",
104
+ dryRun: false,
105
+ skipBuild: false,
106
+ enableLegacyTools: false,
107
+ verbose: false,
108
+ help: false,
109
+ global: false,
110
+ uninstall: false,
111
+ }
112
+ for (const a of argv) {
113
+ switch (a) {
114
+ case "--global": out.mode = "global"; break
115
+ case "--project": out.mode = "project"; break
116
+ case "--uninstall": out.action = "uninstall"; break
117
+ case "--dry-run": out.dryRun = true; break
118
+ case "--skip-build": out.skipBuild = true; break
119
+ case "--enable-legacy-tools": out.enableLegacyTools = true; break
120
+ case "--verbose": out.verbose = true; break
121
+ case "-h":
122
+ case "--help": out.help = true; break
123
+ default:
124
+ // 容忍 PS 风格 flag(来自旧 bin 调用),静默忽略未知,避免炸
125
+ if (a.startsWith("-")) {
126
+ // 兼容 -Global/-DryRun/-Uninstall/-SkipBuild/-EnableLegacyTools
127
+ const low = a.toLowerCase()
128
+ if (low === "-global") out.mode = "global"
129
+ else if (low === "-uninstall") out.action = "uninstall"
130
+ else if (low === "-dryrun") out.dryRun = true
131
+ else if (low === "-skipbuild") out.skipBuild = true
132
+ else if (low === "-enablelegacytools") out.enableLegacyTools = true
133
+ else if (low === "-verbose") out.verbose = true
134
+ }
135
+ break
136
+ }
137
+ }
138
+ // 扁平 boolean 别名(供测试 / 外部消费;main 内部仍用 mode/action)
139
+ out.global = out.mode === "global"
140
+ out.uninstall = out.action === "uninstall"
141
+ return out
142
+ }
143
+
144
+ // .md 文件是否应被 copy(排除 README/_*/.bak/隐藏文件)
145
+ function shouldCopyMd(base) {
146
+ if (!base.endsWith(".md")) return false
147
+ if (base === "README.md") return false
148
+ if (base.startsWith("_")) return false
149
+ if (base.endsWith(".bak")) return false
150
+ if (base.startsWith(".")) return false
151
+ return true
152
+ }
153
+
154
+ // ────────────────────────────────────────────────────────────────────
155
+ // B. 路径 / 常量(照搬两脚本并集)
156
+ // ────────────────────────────────────────────────────────────────────
157
+ function xdgConfigHome() {
158
+ if (process.env.XDG_CONFIG_HOME) return process.env.XDG_CONFIG_HOME
159
+ // Windows:opencode 默认用 %APPDATA%\opencode(与 XDG 不同)
160
+ if (IS_WIN && process.env.APPDATA) return process.env.APPDATA
161
+ return path.join(os.homedir(), ".config")
162
+ }
163
+
164
+ function resolvePaths({ mode }) {
165
+ const targetRoot = mode === "global"
166
+ ? path.join(xdgConfigHome(), "opencode")
167
+ : path.join(process.cwd(), ".opencode")
168
+ const codeforgeCfgDir = path.join(xdgConfigHome(), "codeforge")
169
+ return { targetRoot, codeforgeCfgDir }
170
+ }
171
+
172
+ // v0.1 之前装的目录(卸载时一并清掉)
173
+ const LEGACY_DIRS = ["agent", "command", "tool", "tools", "plugin", "plugins", "lib"]
174
+ // v0.1+ 才有的目录
175
+ const MANAGED_DIRS = ["codeforge", "agents", "commands", "workflows", "context-templates", "review-profiles", "agent-templates"]
176
+ // 细粒度卸载:只删 CodeForge 自己的 skill
177
+ const OWNED_SKILLS = ["ambiguity-gate", "devils-advocate", "ears-zh", "example-mapping", "success-criteria", "weighted-dimensions"]
178
+
179
+ // 文件级 copy(.md,排除 README/_*/.bak/隐藏)
180
+ const MD_COPY_DIRS = [["agents", "agents"], ["commands", "commands"]]
181
+ // 整目录拷贝
182
+ const COPY_DIRS = [
183
+ ["workflows", "workflows"],
184
+ ["context-templates", "context-templates"],
185
+ ["review-profiles", "review-profiles"],
186
+ ["agent-templates", "agent-templates"],
187
+ ]
188
+
189
+ const BUNDLE_SRC_REL = "dist/index.js"
190
+ const BUNDLE_DST_REL = "codeforge/index.js"
191
+
192
+ // ────────────────────────────────────────────────────────────────────
193
+ // 通用文件工具
194
+ // ────────────────────────────────────────────────────────────────────
195
+ function ensureDir(p) {
196
+ run(`mkdir -p ${p}`, () => fs.mkdirSync(p, { recursive: true }))
197
+ }
198
+
199
+ function rmrf(p) {
200
+ run(`rm -rf ${p}`, () => fs.rmSync(p, { recursive: true, force: true }))
201
+ }
202
+
203
+ function commandExists(cmd) {
204
+ try {
205
+ const r = spawnSync(cmd, ["--version"], { stdio: "pipe", shell: IS_WIN })
206
+ return r.status === 0 || (r.error == null && r.status != null)
207
+ } catch {
208
+ return false
209
+ }
210
+ }
211
+
212
+ // ────────────────────────────────────────────────────────────────────
213
+ // C. WSL guard(决策 2:warn + continue,不阻断)
214
+ // ────────────────────────────────────────────────────────────────────
215
+ //
216
+ // 仅 Linux 生效。触发条件:存在 wslpath 命令,或 CODEFORGE_FORCE_WSL_CHECK=1。
217
+ // $HOME 落在 /mnt/[cde]/ 时不再 exit,改 addWarn + continue(quiet 也进摘要)。
218
+ // --uninstall 整段跳过(连 warn 都不打)。
219
+ // CODEFORGE_ALLOW_WSL_WINDOWS_HOME 退化为纯 no-op:不打印任何消息、不抑制 warn。
220
+ function checkWslGuard({ action }) {
221
+ if (process.platform !== "linux") return
222
+ if (action === "uninstall") return
223
+ if (process.env.CODEFORGE_ALLOW_WSL_WINDOWS_HOME === "1") {
224
+ // 退化为兼容 no-op:整段跳过(既不打 bypass 消息,也不再走 warn 逻辑)
225
+ return
226
+ }
227
+ const forced = process.env.CODEFORGE_FORCE_WSL_CHECK === "1"
228
+ if (!forced && !commandExists("wslpath")) return
229
+ const home = process.env.HOME || os.homedir()
230
+ if (/^\/mnt\/[cde]\//.test(home)) {
231
+ addWarn(`在 WSL 中 $HOME=${home} 落在 Windows 挂载路径,建议改用 WSL 原生 home(/home/<user>/)`)
232
+ }
233
+ }
234
+
235
+ // ────────────────────────────────────────────────────────────────────
236
+ // D. opencode.json 读写(纯 Node JSON + 原子写)
237
+ // ────────────────────────────────────────────────────────────────────
238
+ function pluginUri(targetRoot) {
239
+ const abs = path.join(targetRoot, BUNDLE_DST_REL)
240
+ const norm = abs.replace(/\\/g, "/")
241
+ // Windows 盘符 → 三斜杠:file:///C:/Users/...
242
+ if (/^[A-Za-z]:\//.test(norm)) return `file:///${norm}`
243
+ return `file://${norm}`
244
+ }
245
+
246
+ function opencodeCfgPath(targetRoot) {
247
+ return path.join(targetRoot, "opencode.json")
248
+ }
249
+
250
+ // 原子写 JSON:tmp 与目标同目录 → 同分区 → rename 原子(跨平台安全)
251
+ // 禁止使用 os.tmpdir():Windows 上多在 C: 盘,若目标在 D: 盘则 rename 跨驱动器抛 EXDEV。
252
+ function atomicWriteJson(p, data) {
253
+ const tmp = path.join(path.dirname(p), ".tmp-codeforge-" + process.pid)
254
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8")
255
+ fs.renameSync(tmp, p) // 同目录,不跨驱动器
256
+ }
257
+
258
+ function writePluginEntry({ targetRoot }) {
259
+ const cfg = opencodeCfgPath(targetRoot)
260
+ const uri = pluginUri(targetRoot)
261
+ if (DRY_RUN) {
262
+ vlog(`[dry-run] write ${cfg} plugin entry: ${uri}`)
263
+ return
264
+ }
265
+ ensureDir(targetRoot)
266
+ let data = {}
267
+ if (fs.existsSync(cfg)) {
268
+ try {
269
+ data = JSON.parse(fs.readFileSync(cfg, "utf8"))
270
+ } catch {
271
+ fs.copyFileSync(cfg, cfg + ".bak." + Date.now())
272
+ data = {}
273
+ }
274
+ }
275
+ if (!data.$schema) data.$schema = "https://opencode.ai/config.json"
276
+ if (!Array.isArray(data.plugin)) data.plugin = []
277
+ const cleaned = []
278
+ for (const e of data.plugin) {
279
+ const s = String(e)
280
+ if (/\/codeforge\/index\.js$/.test(s)) continue
281
+ if (/\/plugins\/[^/]+\.ts$/.test(s) && /opencode/.test(s)) continue
282
+ if (/\/\.opencode\/plugins\//.test(s)) continue
283
+ cleaned.push(e)
284
+ }
285
+ cleaned.push(uri)
286
+ data.plugin = cleaned
287
+ atomicWriteJson(cfg, data)
288
+ vok(`opencode.json 已写入 plugin entry: ${uri}`)
289
+ }
290
+
291
+ function removePluginEntry({ targetRoot }) {
292
+ const cfg = opencodeCfgPath(targetRoot)
293
+ if (!fs.existsSync(cfg)) return
294
+ if (DRY_RUN) {
295
+ vlog(`[dry-run] rewrite ${cfg} without codeforge plugin entry`)
296
+ return
297
+ }
298
+ let data
299
+ try {
300
+ data = JSON.parse(fs.readFileSync(cfg, "utf8"))
301
+ } catch {
302
+ return
303
+ }
304
+ if (!Array.isArray(data.plugin)) return
305
+ data.plugin = data.plugin.filter((e) => {
306
+ const s = String(e)
307
+ if (/\/codeforge\/index\.js$/.test(s)) return false
308
+ if (/\/plugins\/[^/]+\.ts$/.test(s)) return false
309
+ if (/\/\.opencode\/plugins\//.test(s)) return false
310
+ return true
311
+ })
312
+ atomicWriteJson(cfg, data)
313
+ vok(`opencode.json 已移除 codeforge plugin entry`)
314
+ }
315
+
316
+ // 配置默认 agent → codeforge(仅 global)。三态幂等:SET/UNCHANGED vlog;SKIPPED→addWarn。
317
+ function configureDefaultAgent({ mode }) {
318
+ if (mode !== "global") {
319
+ vok(`项目级安装,跳过用户级 default_agent 配置(要装请加 --global)`)
320
+ return
321
+ }
322
+ const opencodeJson = path.join(xdgConfigHome(), "opencode", "opencode.json")
323
+ const target = "codeforge"
324
+ if (!fs.existsSync(opencodeJson)) {
325
+ addWarn(`opencode.json 不存在,跳过 default_agent 配置(首次安装 opencode 后请重跑 install)`)
326
+ return
327
+ }
328
+ if (DRY_RUN) {
329
+ vlog(`[dry-run] configure default_agent=${target} in ${opencodeJson}`)
330
+ return
331
+ }
332
+ let cfg
333
+ try {
334
+ cfg = JSON.parse(fs.readFileSync(opencodeJson, "utf8"))
335
+ } catch (e) {
336
+ addWarn(`default_agent 配置失败(opencode.json 解析错误,不阻塞 install)`)
337
+ return
338
+ }
339
+ const current = cfg.default_agent
340
+ if (current === undefined) {
341
+ cfg.default_agent = target
342
+ atomicWriteJson(opencodeJson, cfg)
343
+ vok(`default_agent: SET default_agent=${target}`)
344
+ } else if (current === target) {
345
+ vok(`default_agent: UNCHANGED (already ${target})`)
346
+ } else {
347
+ addWarn(`default_agent 已被用户设为 "${current}",跳过不动(不覆盖用户配置)`)
348
+ }
349
+ }
350
+
351
+ // 卸载时还原 default_agent(仅当当前值是 codeforge)
352
+ function restoreDefaultAgent({ mode }) {
353
+ if (mode === "project") return
354
+ const opencodeJson = path.join(xdgConfigHome(), "opencode", "opencode.json")
355
+ if (!fs.existsSync(opencodeJson)) return
356
+ if (DRY_RUN) {
357
+ vlog(`[dry-run] restore default_agent in ${opencodeJson} (remove if codeforge)`)
358
+ return
359
+ }
360
+ let cfg
361
+ try {
362
+ cfg = JSON.parse(fs.readFileSync(opencodeJson, "utf8"))
363
+ } catch {
364
+ return
365
+ }
366
+ if (cfg.default_agent === "codeforge") {
367
+ delete cfg.default_agent
368
+ atomicWriteJson(opencodeJson, cfg)
369
+ vok(`default_agent: REMOVED (was codeforge)`)
370
+ }
371
+ }
372
+
373
+ // ────────────────────────────────────────────────────────────────────
374
+ // E. 文件分发(全 vlog,不进 summary)
375
+ // ────────────────────────────────────────────────────────────────────
376
+ function detectOpencode() {
377
+ const r = spawnSync("opencode", ["--version"], { stdio: "pipe", encoding: "utf8", shell: IS_WIN })
378
+ if (r.status === 0) {
379
+ vok(`检测到 opencode: ${(r.stdout ?? "").trim()}`)
380
+ } else {
381
+ addWarn(`opencode 未检测到,请先安装:https://opencode.ai`)
382
+ }
383
+ }
384
+
385
+ function buildBundle({ skipBuild }) {
386
+ const bundleSrc = path.join(SOURCE_ROOT, BUNDLE_SRC_REL)
387
+ if (skipBuild) {
388
+ vlog(`已跳过 build(--skip-build),使用现有 dist/index.js`)
389
+ } else if (DRY_RUN) {
390
+ vlog(`[dry-run] npm run build`)
391
+ } else {
392
+ const r = spawnSync("npm", ["run", "build"], { cwd: SOURCE_ROOT, stdio: VERBOSE ? "inherit" : "pipe", shell: IS_WIN })
393
+ if (r.status !== 0) {
394
+ if (!VERBOSE) {
395
+ if (r.stdout) process.stderr.write(r.stdout)
396
+ if (r.stderr) process.stderr.write(r.stderr)
397
+ }
398
+ err(`npm run build 失败 (exit=${r.status ?? 1})`)
399
+ process.exit(1)
400
+ }
401
+ }
402
+ if (!DRY_RUN && !fs.existsSync(bundleSrc)) {
403
+ if (skipBuild) {
404
+ err(`找不到 ${bundleSrc}(npm 包可能损坏)`)
405
+ err(` 请尝试重装:npx @andyqiu/codeforge install [--global]`)
406
+ } else {
407
+ err(`找不到 ${bundleSrc},请先成功执行 npm run build`)
408
+ }
409
+ process.exit(1)
410
+ }
411
+ if (fs.existsSync(bundleSrc)) {
412
+ const size = fs.statSync(bundleSrc).size
413
+ vok(`bundle 已就绪: ${bundleSrc} (${size} bytes)`)
414
+ }
415
+ return bundleSrc
416
+ }
417
+
418
+ function cleanLegacy({ targetRoot }) {
419
+ ensureDir(targetRoot)
420
+ for (const legacy of LEGACY_DIRS) {
421
+ const p = path.join(targetRoot, legacy)
422
+ if (fs.existsSync(p) || isSymlink(p)) {
423
+ rmrf(p)
424
+ vlog(`已清理 legacy 目录: ${p}`)
425
+ }
426
+ }
427
+ }
428
+
429
+ function isSymlink(p) {
430
+ try {
431
+ return fs.lstatSync(p).isSymbolicLink()
432
+ } catch {
433
+ return false
434
+ }
435
+ }
436
+
437
+ function installBundle({ targetRoot, bundleSrc }) {
438
+ const bundleDst = path.join(targetRoot, BUNDLE_DST_REL)
439
+ ensureDir(path.dirname(bundleDst))
440
+ run(`cp ${bundleSrc} ${bundleDst}`, () => fs.copyFileSync(bundleSrc, bundleDst))
441
+ vok(`bundle → ${bundleDst}`)
442
+ writePluginEntry({ targetRoot })
443
+ // 写 VERSION marker
444
+ let version = "unknown"
445
+ try {
446
+ version = JSON.parse(fs.readFileSync(path.join(SOURCE_ROOT, "package.json"), "utf8")).version ?? "unknown"
447
+ } catch {}
448
+ const versionFile = path.join(targetRoot, "codeforge", "VERSION")
449
+ run(`write VERSION ${version}`, () => fs.writeFileSync(versionFile, version + "\n", "utf8"))
450
+ vok(`VERSION → ${versionFile} (${version})`)
451
+ }
452
+
453
+ function installMdDirs({ targetRoot }) {
454
+ for (const [srcName, dstName] of MD_COPY_DIRS) {
455
+ const srcPath = path.join(SOURCE_ROOT, srcName)
456
+ const dstPath = path.join(targetRoot, dstName)
457
+ if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isDirectory()) {
458
+ vlog(`源目录不存在,跳过: ${srcPath}`)
459
+ continue
460
+ }
461
+ if (fs.existsSync(dstPath) || isSymlink(dstPath)) rmrf(dstPath)
462
+ ensureDir(dstPath)
463
+ let count = 0
464
+ const entries = DRY_RUN ? safeReaddir(srcPath) : fs.readdirSync(srcPath)
465
+ for (const base of entries) {
466
+ if (!shouldCopyMd(base)) continue
467
+ const f = path.join(srcPath, base)
468
+ try {
469
+ if (!fs.statSync(f).isFile()) continue
470
+ } catch {
471
+ continue
472
+ }
473
+ run(`cp ${f} ${dstPath}/`, () => fs.copyFileSync(f, path.join(dstPath, base)))
474
+ count++
475
+ }
476
+ vok(`${srcName}/ → ${dstPath} (${count} 个 .md)`)
477
+ }
478
+ }
479
+
480
+ function safeReaddir(p) {
481
+ try {
482
+ return fs.readdirSync(p)
483
+ } catch {
484
+ return []
485
+ }
486
+ }
487
+
488
+ function installCopyDirs({ targetRoot }) {
489
+ for (const [srcName, dstName] of COPY_DIRS) {
490
+ const srcPath = path.join(SOURCE_ROOT, srcName)
491
+ const dstPath = path.join(targetRoot, dstName)
492
+ if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isDirectory()) {
493
+ vlog(`源目录不存在,跳过: ${srcPath}`)
494
+ continue
495
+ }
496
+ ensureDir(dstPath)
497
+ run(`cp -R ${srcPath}/. ${dstPath}/`, () => fs.cpSync(srcPath, dstPath, { recursive: true }))
498
+ vok(`${srcName}/ → ${dstPath} (整目录拷贝)`)
499
+ }
500
+ }
501
+
502
+ function installSkills({ targetRoot }) {
503
+ const skillsSrc = path.join(SOURCE_ROOT, "skills")
504
+ const skillsDst = path.join(targetRoot, "skills")
505
+ if (!fs.existsSync(skillsSrc) || !fs.statSync(skillsSrc).isDirectory()) {
506
+ vlog(`skills/ 目录不存在,跳过(发布包未含 skills)`)
507
+ return
508
+ }
509
+ ensureDir(skillsDst)
510
+ let count = 0
511
+ const entries = DRY_RUN ? safeReaddir(skillsSrc) : fs.readdirSync(skillsSrc)
512
+ for (const name of entries) {
513
+ const skillDir = path.join(skillsSrc, name)
514
+ try {
515
+ if (!fs.statSync(skillDir).isDirectory()) continue
516
+ } catch {
517
+ continue
518
+ }
519
+ const dstSkill = path.join(skillsDst, name)
520
+ if (fs.existsSync(dstSkill) || isSymlink(dstSkill)) rmrf(dstSkill)
521
+ run(`cp -R ${skillDir} ${dstSkill}`, () => fs.cpSync(skillDir, dstSkill, { recursive: true }))
522
+ count++
523
+ }
524
+ vok(`skills/ → ${skillsDst} (${count} 个 skill)`)
525
+ }
526
+
527
+ function installAssets({ targetRoot }) {
528
+ const assetsSrc = path.join(SOURCE_ROOT, "assets")
529
+ const assetsDst = path.join(targetRoot, "assets")
530
+ if (!fs.existsSync(assetsSrc) || !fs.statSync(assetsSrc).isDirectory()) {
531
+ vlog(`assets/ 目录不存在,跳过(发布包未含 assets)`)
532
+ return
533
+ }
534
+ ensureDir(assetsDst)
535
+ run(`cp -R ${assetsSrc}/. ${assetsDst}/`, () => fs.cpSync(assetsSrc, assetsDst, { recursive: true }))
536
+ vok(`assets/ → ${assetsDst}`)
537
+ }
538
+
539
+ // 本仓库 dev 期:install 会清掉 .opencode/plugins/,需重生成 dev shim(ADR-0055)
540
+ function regenerateDevShim() {
541
+ const marker = path.join(SOURCE_ROOT, ".codeforge", ".dev-marker")
542
+ const devSync = path.join(SOURCE_ROOT, "scripts", "dev-sync.mjs")
543
+ if (!fs.existsSync(marker) || !fs.existsSync(devSync)) return
544
+ if (DRY_RUN) {
545
+ vlog(`[dry-run] node scripts/dev-sync.mjs --no-build`)
546
+ return
547
+ }
548
+ spawnSync(process.execPath, [devSync, "--no-build"], { cwd: SOURCE_ROOT, stdio: "pipe" })
549
+ vlog(`dev shim regenerated (.opencode/plugins/codeforge-dev.js)`)
550
+ }
551
+
552
+ // 仅 global:写 ~/.config/codeforge/kh.json(已存在则跳过)
553
+ function installKhConfig({ mode, codeforgeCfgDir }) {
554
+ if (mode !== "global") return
555
+ const khJson = path.join(codeforgeCfgDir, "kh.json")
556
+ if (fs.existsSync(khJson)) {
557
+ vlog(`kh.json 已存在,跳过: ${khJson}`)
558
+ return
559
+ }
560
+ if (DRY_RUN) {
561
+ vlog(`[dry-run] write ${khJson}`)
562
+ return
563
+ }
564
+ ensureDir(codeforgeCfgDir)
565
+ atomicWriteJson(khJson, {})
566
+ vok(`kh.json → ${khJson}`)
567
+ }
568
+
569
+ // ────────────────────────────────────────────────────────────────────
570
+ // F. 自检(verifyInstall:通过静默,失败按定级表 addWarn——全部 warn,不改 exit)
571
+ // ────────────────────────────────────────────────────────────────────
572
+ function verifyInstall({ mode, targetRoot }) {
573
+ // 1. git 版本 >= 2.5
574
+ if (commandExists("git")) {
575
+ const r = spawnSync("git", ["--version"], { stdio: "pipe", encoding: "utf8", shell: IS_WIN })
576
+ const m = /(\d+)\.(\d+)/.exec(r.stdout ?? "")
577
+ const major = m ? parseInt(m[1], 10) : 0
578
+ const minor = m ? parseInt(m[2], 10) : 0
579
+ if (major > 2 || (major === 2 && minor >= 5)) {
580
+ vok(`git ${m ? m[0] : "?"} (>= 2.5 ✓ worktree 支持)`)
581
+ } else {
582
+ addWarn(`git 版本太旧(worktree 需要 >= 2.5);worktree 隔离将失效`)
583
+ }
584
+ } else {
585
+ addWarn(`git 未安装;worktree 隔离将失效(写操作会被 DENY)`)
586
+ }
587
+
588
+ // 1b. git rev-parse --git-dir
589
+ if (commandExists("git")) {
590
+ const r = spawnSync("git", ["rev-parse", "--git-dir"], { stdio: "pipe", shell: IS_WIN })
591
+ if (r.status === 0) {
592
+ vok(`当前目录是 git 仓库(git rev-parse --git-dir ✓)`)
593
+ } else if (mode === "project") {
594
+ addWarn(`当前目录不是 git 仓库;worktree 隔离将失效(git init && git add -A && git commit)`)
595
+ } else {
596
+ vlog(`当前目录非 git 仓库(全局安装无碍)`)
597
+ }
598
+ }
599
+
600
+ // 2. opencode 已安装
601
+ if (commandExists("opencode")) {
602
+ const r = spawnSync("opencode", ["--version"], { stdio: "pipe", encoding: "utf8", shell: IS_WIN })
603
+ vok(`opencode ${(r.stdout ?? "").trim() || "unknown"}`)
604
+ } else {
605
+ addWarn(`opencode 未在 PATH 中;CodeForge 无入口可用(请先安装 opencode)`)
606
+ }
607
+
608
+ // 3. plugin 注册检查
609
+ const cfg = opencodeCfgPath(targetRoot)
610
+ let registered = false
611
+ if (fs.existsSync(cfg)) {
612
+ try {
613
+ registered = /"codeforge"/.test(fs.readFileSync(cfg, "utf8"))
614
+ } catch {}
615
+ }
616
+ if (registered) {
617
+ vok(`plugin 已注册:${cfg}`)
618
+ } else if (mode === "global") {
619
+ addWarn(`plugin 未在 ${cfg} 中注册`)
620
+ } else {
621
+ vlog(`项目级配置 ${cfg} 未包含 codeforge entry(项目级可被全局兜底)`)
622
+ }
623
+
624
+ // 4. dev-marker 误用检测(仅 global)
625
+ if (mode === "global") {
626
+ const m1 = path.join(os.homedir(), ".codeforge", ".dev-marker")
627
+ const m2 = path.join(os.homedir(), ".dev-marker")
628
+ if (fs.existsSync(m1) || fs.existsSync(m2)) {
629
+ addWarn(`检测到 $HOME 附近有 .dev-marker —— 可能导致所有项目让位到本地 plugin(建议删除)`)
630
+ }
631
+ }
632
+ }
633
+
634
+ // ────────────────────────────────────────────────────────────────────
635
+ // G. 卸载
636
+ // ────────────────────────────────────────────────────────────────────
637
+ function uninstall({ mode, targetRoot }) {
638
+ vlog(`卸载 CodeForge from: ${targetRoot}`)
639
+ removePluginEntry({ targetRoot })
640
+ if (mode !== "project") restoreDefaultAgent({ mode })
641
+ for (const name of [...LEGACY_DIRS, ...MANAGED_DIRS]) {
642
+ const p = path.join(targetRoot, name)
643
+ if (fs.existsSync(p) || isSymlink(p)) {
644
+ rmrf(p)
645
+ vok(`已删除 ${p}`)
646
+ }
647
+ }
648
+ for (const skillName of OWNED_SKILLS) {
649
+ const p = path.join(targetRoot, "skills", skillName)
650
+ if (fs.existsSync(p) || isSymlink(p)) {
651
+ rmrf(p)
652
+ vok(`已删除 skill: ${p}`)
653
+ }
654
+ }
655
+ }
656
+
657
+ // ────────────────────────────────────────────────────────────────────
658
+ // main
659
+ // ────────────────────────────────────────────────────────────────────
660
+ function printHelp() {
661
+ console.log(`CodeForge install.mjs — 零侵入安装到 opencode(Node ESM 零依赖)
662
+
663
+ 用法:
664
+ node install.mjs # 项目级(默认)
665
+ node install.mjs --global # 全局
666
+ node install.mjs --uninstall # 卸载
667
+ node install.mjs --dry-run # 仅打印操作,不执行
668
+ node install.mjs --skip-build # 跳过 npm run build
669
+ node install.mjs --verbose # 展开全部过程输出`)
670
+ }
671
+
672
+ function getVersion() {
673
+ try {
674
+ return JSON.parse(fs.readFileSync(path.join(SOURCE_ROOT, "package.json"), "utf8")).version ?? "unknown"
675
+ } catch {
676
+ return "unknown"
677
+ }
678
+ }
679
+
680
+ function main() {
681
+ const opts = parseArgs(process.argv.slice(2))
682
+ VERBOSE = opts.verbose
683
+ DRY_RUN = opts.dryRun
684
+
685
+ if (opts.help) {
686
+ printHelp()
687
+ return 0
688
+ }
689
+
690
+ const { targetRoot, codeforgeCfgDir } = resolvePaths({ mode: opts.mode })
691
+ const version = getVersion()
692
+
693
+ // WSL guard(非 uninstall 时跑)
694
+ checkWslGuard({ action: opts.action })
695
+
696
+ if (VERBOSE) {
697
+ vlog(`CodeForge installer (mode=${opts.mode}, action=${opts.action}, dry-run=${DRY_RUN})`)
698
+ vlog(`Source : ${SOURCE_ROOT}`)
699
+ vlog(`Target : ${targetRoot}`)
700
+ }
701
+
702
+ if (opts.action === "uninstall") {
703
+ uninstall({ mode: opts.mode, targetRoot })
704
+ if (VERBOSE) {
705
+ for (const w of summaryWarns) console.log(`${C.yellow}⚠${C.reset} ${w}`)
706
+ } else {
707
+ for (const w of summaryWarns) console.log(`${C.yellow}⚠${C.reset} ${w}`)
708
+ }
709
+ console.log(`CodeForge 已卸载 ${C.green}✓${C.reset}`)
710
+ console.log(`opencode 自身和你的 AGENTS.md 不会被动`)
711
+ return 0
712
+ }
713
+
714
+ // 安装流程
715
+ detectOpencode()
716
+ const bundleSrc = buildBundle({ skipBuild: opts.skipBuild })
717
+ cleanLegacy({ targetRoot })
718
+ installBundle({ targetRoot, bundleSrc })
719
+ installMdDirs({ targetRoot })
720
+ installCopyDirs({ targetRoot })
721
+ installSkills({ targetRoot })
722
+ installAssets({ targetRoot })
723
+ configureDefaultAgent({ mode: opts.mode })
724
+ installKhConfig({ mode: opts.mode, codeforgeCfgDir })
725
+ regenerateDevShim()
726
+ verifyInstall({ mode: opts.mode, targetRoot })
727
+
728
+ printSummary({ version, targetRoot, dryRun: DRY_RUN })
729
+ return 0
730
+ }
731
+
732
+ // 纯函数 export 供 vitest 直调
733
+ export {
734
+ parseArgs,
735
+ pluginUri,
736
+ shouldCopyMd,
737
+ atomicWriteJson,
738
+ resolvePaths,
739
+ checkWslGuard,
740
+ LEGACY_DIRS,
741
+ MANAGED_DIRS,
742
+ OWNED_SKILLS,
743
+ }
744
+
745
+ // main 守卫:仅作为脚本直接运行时执行
746
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
747
+ try {
748
+ const code = main()
749
+ process.exit(typeof code === "number" ? code : 0)
750
+ } catch (e) {
751
+ err(`安装失败:${e?.stack ?? e}`)
752
+ process.exit(1)
753
+ }
754
+ }