From 20a3b487fdf74c536a939c4ae145e7706775e21c Mon Sep 17 00:00:00 2001 From: ypshidifu Date: Wed, 23 Sep 2026 13:47:10 +0800 Subject: [PATCH] =?UTF-8?q?TUI=E6=8F=92=E4=BB=B6=EF=BC=9Amcpcfg=E9=94=AE?= =?UTF-8?q?=E5=80=BC=E5=8C=96+=E8=B7=AF=E5=BE=84=E8=87=AA=E6=84=88+?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E6=97=A0=E6=9D=A1=E4=BB=B6=E6=B3=A8=E5=86=8C?= =?UTF-8?q?+mcp.on.flag=E9=98=B2=E5=BD=92=E4=BD=8D=EF=BC=9B=E9=99=84readMc?= =?UTF-8?q?pCfg=E5=8D=95=E6=B5=8B15=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui-plugins/oc-session-all.js | 220 ++++++++++++++++++++++++++++---- tui-plugins/readmcpcfg.test.mjs | 94 ++++++++++++++ 2 files changed, 287 insertions(+), 27 deletions(-) create mode 100644 tui-plugins/readmcpcfg.test.mjs diff --git a/tui-plugins/oc-session-all.js b/tui-plugins/oc-session-all.js index 2ebc35a..420699a 100644 --- a/tui-plugins/oc-session-all.js +++ b/tui-plugins/oc-session-all.js @@ -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 { diff --git a/tui-plugins/readmcpcfg.test.mjs b/tui-plugins/readmcpcfg.test.mjs new file mode 100644 index 0000000..01d6c28 --- /dev/null +++ b/tui-plugins/readmcpcfg.test.mjs @@ -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)