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