TUI插件:mcpcfg键值化+路径自愈+命令无条件注册+mcp.on.flag防归位;附readMcpCfg单测15例

This commit is contained in:
ypshidifu
2026-09-23 13:47:10 +08:00
parent b65d3d6879
commit 20a3b487fd
2 changed files with 287 additions and 27 deletions
+193 -27
View File
@@ -7,6 +7,7 @@ import {
writeFileSync,
readdirSync,
statSync,
rmSync,
} from "fs"
import { join, basename } from "path"
import { homedir } from "os"
@@ -46,32 +47,119 @@ function projectDisplayName(session, projectMap) {
return session.projectID ? session.projectID.slice(0, 8) : "unknown"
}
// ── MCP 配置对调(mcpcfg.txt 三行驱动,小白只改这个 txt)─────────────────
// 第1行:模式 auto/manual/off(中英别名),空或未知=自动
// 自动 = 启动时把活配置归位为安静版(无 mcp,零 toast);
// ── MCP 配置对调(mcpcfg.txt 键值驱动,小白只改这个 txt)─────────────────
// 推荐格式(键值,带注释):
// # MCP 开关配置:改完保存,重启 OC 生效
// 模式: 自动 ← 自动 / 手动 / 关闭(也认 auto/manual/off)
// 含MCP的配置: C:\...\opencode.json
// 安静配置: C:\...\opencode.json.2
// 兼容旧「裸三行」格式(第1行模式、第2行含MCP路径、第3行安静路径),
// 读到旧格式会自动迁移写回键值格式。
// 自动 = 无显式开启标记时,启动把活配置归位为安静版(零 toast);
// /mcpon /mcpoff 与大模型 server 插件都可切换(命令=手动切换一次状态)
// 手动 = 启动不动文件,仅 /mcpon /mcpoff 触发对调(大模型插件会拒绝)
// 关闭 = 本功能整体禁用(不注册命令、不 connect、不动文件)
// 第2行:含 MCP 的原始配置路径
// 第3行:删了 MCP 的备份(安静)配置路径
// 对调 = 交换第2/3行两文件的内容,活文件永远是名为 opencode.json 的那份
// 对调 = 交换两文件的内容,活文件永远是名为 opencode.json 的那份。
// 路径失效时自动扫描同目录 opencode.json.* 自愈并写回,避免配置错指向
// 把功能锁死(历史 bug:指向的备份文件被删 → 命令整个消失)。
const CFG_TXT = join(LOG_DIR, "mcpcfg.txt")
const ON_FLAG = join(LOG_DIR, "mcp.on.flag") // 显式开启标记(/mcpon 写入,/mcpoff 删除)
function parseMode(raw) {
const key = (raw || "").trim().toLowerCase()
if (["auto", "audo", "自动"].includes(key) || key === "") return "auto"
if (["manual", "hand", "on", "手动"].includes(key)) return "manual"
if (["off", "disable", "disabled", "关闭"].includes(key)) return "off"
return "auto"
}
function writeMcpCfg(mode, onPath, quietPath) {
const modeCn = mode === "auto" ? "自动" : mode === "manual" ? "手动" : "关闭"
const body = [
"# MCP 开关配置:改完保存,重启 OC 生效(第1个键=模式)",
"# 模式可填:自动(大模型可切换) / 手动(仅 /mcpon /mcpoff) / 关闭(禁用本功能)",
"模式: " + modeCn,
"含MCP的配置: " + onPath,
"安静配置: " + quietPath,
"",
].join("\n")
try {
writeFileSync(CFG_TXT, body, "utf8")
return true
} catch (e) {
log("[oc-session-all] mcpcfg 写回失败: " + e.message)
return false
}
}
function jsonFileHasMcp(p) {
try {
const j = JSON.parse(readFileSync(p, "utf8"))
return !!(j && j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0)
} catch (e) {
return false
}
}
// 路径自愈:给定活文件与缺失行的原值,扫同目录找互补的影子文件
function healShadowPath(activePath, missingVal) {
try {
const dir = dirname(activePath)
const activeHas = jsonFileHasMcp(activePath)
const cands = readdirSync(dir)
.filter((n) => n.startsWith("opencode.json.") && n !== "opencode.json.jsonc")
.filter((n) => !n.endsWith(".jsonc") && !n.startsWith("."))
.map((n) => join(dir, n))
.filter((p) => existsSync(p))
// 优先:内容与活文件互补的那份(活含 mcp → 影子必须不含,反之亦然)
const complementary = cands.filter((p) => jsonFileHasMcp(p) !== activeHas)
if (complementary.length > 0) return complementary[0]
if (cands.length > 0) return cands[0]
// 原值虽然没了,但同目录实在没有第二份配置 → 保持原值让上层报清晰错误
return missingVal
} catch (e) {
return missingVal
}
}
function readMcpCfg() {
try {
if (!existsSync(CFG_TXT)) {
return { mode: "off", missing: true, error: "缺少 mcpcfg.txt(配置缺失,功能视为关闭)" }
}
const lines = readFileSync(CFG_TXT, "utf8").split(/\r?\n/)
const raw = (lines[0] || "").trim()
const key = raw.toLowerCase()
let mode
if (["auto", "audo", "自动"].includes(key) || key === "") mode = "auto"
else if (["manual", "hand", "on", "手动"].includes(key)) mode = "manual"
else if (["off", "disable", "disabled", "关闭"].includes(key)) mode = "off"
else mode = "auto"
const onPath = (lines[1] || "").trim()
const quietPath = (lines[2] || "").trim()
const text = readFileSync(CFG_TXT, "utf8")
const allLines = text.split(/\r?\n/)
let mode = null
let onPath = ""
let quietPath = ""
let isLegacy = false
// 先按键值解析(认中英文冒号与中英文键名)
const kv = {}
for (const ln of allLines) {
const line = ln.trim()
if (!line || line.startsWith("#") || line.startsWith("//")) continue
const m = line.match(/^([^::]+)[::]\s*(.*)$/)
if (m) kv[m[1].trim().toLowerCase()] = m[2].trim()
}
const modeVal = kv["模式"] ?? kv["mode"] ?? kv["第1行"]
const onVal = kv["含mcp的配置"] ?? kv["含mcp配置"] ?? kv["含mcp"] ?? kv["on"] ?? kv["原始配置"] ?? kv["第2行"]
const quietVal = kv["安静配置"] ?? kv["安静"] ?? kv["quiet"] ?? kv["备份"] ?? kv["backup"] ?? kv["第3行"]
if (modeVal !== undefined || onVal !== undefined || quietVal !== undefined) {
mode = parseMode(modeVal ?? "自动")
onPath = (onVal || "").trim()
quietPath = (quietVal || "").trim()
} else {
// 旧「裸三行」格式:非注释行按顺序 = 模式 / 含MCP路径 / 安静路径
const bare = allLines.map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("//"))
isLegacy = true
mode = parseMode(bare[0] ?? "自动")
onPath = (bare[1] || "").trim()
quietPath = (bare[2] || "").trim()
}
// 活文件 = 两行中文件名为 opencode.json 的那份(OC 只读这个名字)
let activePath = null
let shadowPath = null
@@ -82,11 +170,56 @@ function readMcpCfg() {
activePath = quietPath
shadowPath = onPath
}
const result = { mode, onPath, quietPath, activePath, shadowPath, missing: false }
if (mode !== "off" && !activePath) {
result.error = "mcpcfg.txt 第2/3行必须有一个是 opencode.json 的完整路径"
} else if (mode !== "off" && (!existsSync(onPath) || !existsSync(quietPath))) {
result.error = "mcpcfg.txt 第2/3行指向的文件不存在(请检查路径)"
let healed = false
const result = { mode, onPath, quietPath, activePath, shadowPath, missing: false, healed: false, error: null }
if (mode !== "off") {
// 路径自愈1:没有活文件名 → 找「存在且名为 opencode.json」的那行
if (!activePath) {
let guess = null
if (onPath && basename(onPath) === "opencode.json" && existsSync(onPath)) guess = onPath
else if (quietPath && basename(quietPath) === "opencode.json" && existsSync(quietPath)) guess = quietPath
if (guess) {
activePath = guess
shadowPath = guess === onPath ? quietPath : onPath
healed = true
}
}
// 路径自愈2:活文件存在但影子文件缺失 → 扫同目录互补配置
if (activePath && existsSync(activePath)) {
if (!shadowPath || !existsSync(shadowPath)) {
const healedShadow = healShadowPath(activePath, shadowPath || "")
if (healedShadow && healedShadow !== shadowPath && existsSync(healedShadow)) {
if (activePath === onPath) quietPath = healedShadow
else onPath = healedShadow
shadowPath = healedShadow
healed = true
}
}
}
result.onPath = onPath
result.quietPath = quietPath
result.activePath = activePath
result.shadowPath = shadowPath
result.healed = healed
if (activePath && shadowPath && activePath !== shadowPath && existsSync(activePath) && existsSync(shadowPath)) {
if (healed || isLegacy) {
if (writeMcpCfg(mode, onPath, quietPath)) {
log("[oc-session-all] mcpcfg 已" + (isLegacy && !healed ? "迁移为键值格式" : "自愈修复路径"))
result.migrated = isLegacy
}
}
} else if (!activePath) {
result.error = "mcpcfg.txt 两行路径都不是 opencode.json(请把「含MCP的配置」或「安静配置」指向名为 opencode.json 的文件)"
if (healed || isLegacy) writeMcpCfg(mode, onPath, quietPath)
} else if (!existsSync(activePath)) {
result.error = "mcpcfg.txt 指向的活配置不存在: " + activePath
} else {
result.error = "mcpcfg.txt 缺少第二个配置文件(同目录需要两份 opencode.json 配置才能对调)"
}
}
return result
} catch (e) {
@@ -136,9 +269,14 @@ async function doReload(context) {
async function setMcp(context, wantOn) {
const cfg = readMcpCfg()
if (cfg.error) return { ok: false, why: cfg.error }
if (cfg.mode === "off") return { ok: false, why: "MCP 开关功能已被关闭(mcpcfg.txt 第1行=关闭)" }
if (cfg.mode === "off") return { ok: false, why: "MCP 开关功能已被关闭(配置「模式: 关闭」)" }
const cur = activeHasMcp(cfg.activePath)
if (cur === wantOn) {
// 状态已一致,但显式意图要落标记(避免下次 setup 归位把它换走)
try {
if (wantOn) writeFileSync(ON_FLAG, String(process.pid))
else if (existsSync(ON_FLAG)) rmSync(ON_FLAG)
} catch (e) {}
return { ok: true, unchanged: true, why: wantOn ? "MCP 已是开启状态" : "MCP 已是关闭(安静)状态" }
}
swapFiles(cfg.onPath, cfg.quietPath)
@@ -147,6 +285,11 @@ async function setMcp(context, wantOn) {
log("[oc-session-all] swap verify FAILED want=" + wantOn + " got=" + now)
return { ok: false, why: "配置对调后状态校验失败(请检查 mcpcfg.txt 两行路径)" }
}
// 记录显式意图:/mcpon 写标记,reload 重建 setup 或下次启动见标记不再归位
try {
if (wantOn) writeFileSync(ON_FLAG, String(process.pid))
else if (existsSync(ON_FLAG)) rmSync(ON_FLAG)
} catch (e) {}
log("[oc-session-all] config swapped -> " + (wantOn ? "WITH mcp" : "QUIET (no mcp)"))
const reloaded = await doReload(context)
return { ok: true, reloaded, why: reloaded ? null : "配置已对调,但热重载调用失败(重启 OC 生效)" }
@@ -169,9 +312,13 @@ export default Plugin.define({
async setup(context) {
log("[oc-session-all] v8 loaded — 会话选择器 + MCP配置对调(mcpcfg.txt) + 热重载 + skill热加载")
// ── 读取 mcpcfg.txt(三行小白配置)────────────────────────────────
// ── 读取 mcpcfg.txt(键值小白配置)────────────────────────────────
const mcfg = readMcpCfg()
const hasMcpNow = mcfg.activePath ? activeHasMcp(mcfg.activePath) : false
const featureOn = !mcfg.error && mcfg.mode !== "off"
// 命令注册门槛与 featureOn 分离:只要模式没被显式「关闭」就注册命令——
// 否则路径写错(cfg.error)会让 /mcpon /mcpoff 一起消失,用户失去自救入口
const commandsOn = mcfg.mode !== "off" && !mcfg.missing
log(
"[oc-session-all] mcpcfg: mode=" +
mcfg.mode +
@@ -179,13 +326,18 @@ export default Plugin.define({
(mcfg.activePath || "(none)") +
" hasMcp=" +
hasMcpNow +
" healed=" +
!!mcfg.healed +
" commandsOn=" +
commandsOn +
(mcfg.error ? " ERROR=" + mcfg.error : ""),
)
const featureOn = !mcfg.error && mcfg.mode !== "off"
// ── 自动模式:启动归位(把活配置换回安静版,保持平时零 toast)────────
// 例外:存在显式开启标记(用户 /mcpon 开过且没关)→ 保留开启态,不归位
const onFlagged = existsSync(ON_FLAG)
let bootSwapReload = false
if (featureOn && mcfg.mode === "auto" && hasMcpNow) {
if (featureOn && mcfg.mode === "auto" && hasMcpNow && !onFlagged) {
try {
swapFiles(mcfg.onPath, mcfg.quietPath)
const still = activeHasMcp(mcfg.activePath)
@@ -198,6 +350,8 @@ export default Plugin.define({
} catch (e) {
log("[oc-session-all] 启动归位 ERROR: " + e.message)
}
} else if (mcfg.mode === "auto" && hasMcpNow && onFlagged) {
log("[oc-session-all] 检测到 mcp.on.flag:保留上次显式开启状态,本次不归位")
}
// ── MCP error toast 拦截(monkey-patch 兜底层)────────────────────
@@ -247,16 +401,28 @@ export default Plugin.define({
await delay(1500)
const cfg = readMcpCfg()
if (cfg.error || cfg.mode === "off") {
log("[oc-session-all] enableMcp skipped: 功能关闭或配置缺失")
log("[oc-session-all] enableMcp skipped: 功能关闭或配置缺失" + (cfg.error ? "(" + cfg.error + ")" : ""))
return
}
if (!activeHasMcp(cfg.activePath)) {
log("[oc-session-all] enableMcp skipped: 当前为安静状态(活配置无 mcp)")
return
}
// 从活配置动态读 MCP server 名(不再写死 baota-mcp)
let wanted = []
try {
const j = JSON.parse(readFileSync(cfg.activePath, "utf8"))
wanted = Object.keys((j.mcp && j.mcp.servers) || {})
} catch (e) {
log("[oc-session-all] enableMcp 读取 server 名失败: " + e.message)
}
if (!wanted.length) {
log("[oc-session-all] enableMcp skipped: 活配置 mcp.servers 为空")
return
}
const deadline = Date.now() + 90000
let attempt = 0
let pending = ["baota-mcp"]
let pending = wanted.slice()
while (pending.length && Date.now() < deadline) {
attempt++
try {
+94
View File
@@ -0,0 +1,94 @@
// readMcpCfg 单测:从 oc-session-all.js 源文件动态提取解析区块来测,
// 保证测的是源码本体(不是复制的逻辑)。
// 场景:1) 键值格式 2) 旧三行格式+自动迁移 3) 路径失效自愈
import { readFileSync, writeFileSync, mkdtempSync, existsSync, rmSync, mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, dirname, basename } from "node:path"
const SRC = join(import.meta.dirname, "oc-session-all.js")
const text = readFileSync(SRC, "utf8")
// 抽取 CFG_TXT 定义到 activeHasMcp 之前的全部函数(含 ON_FLAG/writeMcpCfg/heal/readMcpCfg)
const start = text.indexOf("const CFG_TXT =")
const end = text.indexOf("function activeHasMcp")
if (start < 0 || end < 0 || end < start) {
console.error("EXTRACT_FAIL: markers not found")
process.exit(1)
}
const block = text.slice(start, end)
const work = mkdtempSync(join(tmpdir(), "mcpcfg-test-"))
const LOG_DIR = work
const CFG_TXT = join(work, "mcpcfg.txt")
const ON_FLAG = join(work, "mcp.on.flag")
function log(m) {
/* silent in tests */
}
const moduleSrc = `
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs"
import { join, basename, dirname } from "node:path"
const LOG_DIR = ${JSON.stringify(LOG_DIR)}
function log(m) {}
${block}
export { readMcpCfg, parseMode, writeMcpCfg }
`
const modPath = join(work, "extracted.mjs")
writeFileSync(modPath, moduleSrc, "utf8")
const { pathToFileURL } = await import("node:url")
const { readMcpCfg } = await import(pathToFileURL(modPath).href)
let pass = 0
let fail = 0
function check(name, cond, extra) {
if (cond) {
pass++
console.log("PASS " + name)
} else {
fail++
console.log("FAIL " + name + (extra ? " | " + extra : ""))
}
}
// ---- 场景1:键值格式(注释+中文冒号)----
const active = join(work, "opencode.json")
const shadow = join(work, "opencode.json.2")
writeFileSync(active, JSON.stringify({ mcp: { servers: { a: {} } }, tag: "on" }))
writeFileSync(shadow, JSON.stringify({ tag: "quiet" }))
writeFileSync(
CFG_TXT,
"# 注释行\n模式: 自动\n含MCP的配置: " + active + "\n安静配置: " + shadow + "\n",
"utf8",
)
let r = readMcpCfg()
check("S1 mode=auto", r.mode === "auto", "got=" + r.mode)
check("S1 active识别", r.activePath === active, "got=" + r.activePath)
check("S1 无error", !r.error, "err=" + r.error)
check("S1 不迁移(已是键值)", !r.migrated, "migrated=" + r.migrated)
// ---- 场景2:旧三行格式 -> 解析 + 自动迁移写回 ----
writeFileSync(CFG_TXT, "手动\n" + active + "\n" + shadow + "\n", "utf8")
r = readMcpCfg()
check("S2 mode=manual", r.mode === "manual", "got=" + r.mode)
check("S2 active识别", r.activePath === active, "got=" + r.activePath)
check("S2 迁移写回", existsSync(CFG_TXT) && readFileSync(CFG_TXT, "utf8").includes("模式:"), "no kv rewrite")
check("S2 迁移后路径保留", readFileSync(CFG_TXT, "utf8").includes(shadow), "shadow lost")
check("S2 再读仍正确", readMcpCfg().mode === "manual" && !readMcpCfg().error)
// ---- 场景3:真实 bug 复现 —— 含MCP行指向的备份文件被删(.1 不存在)----
const gone = join(work, "opencode.json.1")
writeFileSync(CFG_TXT, "模式: 自动\n含MCP的配置: " + gone + "\n安静配置: " + active + "\n", "utf8")
r = readMcpCfg()
check("S3 自愈后无error", !r.error, "err=" + r.error)
check("S3 healed=true", !!r.healed, "healed=" + r.healed)
check("S3 active=活文件", r.activePath === active, "ap=" + r.activePath)
check("S3 影子=存在的互补文件", r.shadowPath === shadow || r.quietPath === shadow || r.onPath === shadow,
"shadow=" + r.shadowPath + " on=" + r.onPath + " quiet=" + r.quietPath)
const rewritten = readFileSync(CFG_TXT, "utf8")
check("S3 写回含存在的影子路径", rewritten.includes(shadow), "rewritten=" + rewritten.replace(/\n/g, "|"))
check("S3 写回后二次读取仍OK", (() => { const r2 = readMcpCfg(); return !r2.error && r2.activePath === active })(),
"reread=" + JSON.stringify({ e: readMcpCfg().error }))
rmSync(work, { recursive: true, force: true })
console.log(`\nRESULT: ${pass} passed, ${fail} failed`)
process.exit(fail ? 1 : 0)