#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ mcpctl —— 命令行版的 MCP 配置对调开关(与 TUI 插件 / server 插件同一套逻辑) 做什么: status 查看当前模式与 MCP 开/关状态(只读,不改任何文件) on 把配置对调为「含 MCP」的原始版,并调用 reload 热重载 off 把配置对调为「不含 MCP」的安静版,并调用 reload 热重载 mode 查看或修改 mcpcfg.txt 模式键(自动 / 手动 / 关闭) 模式语义(与插件完全一致): 自动 大模型可通过 server 插件切换;/mcpon /mcpoff 与本工具视为手动切换一次 手动 仅允许 /mcpon /mcpoff 与本工具(用户意图),大模型的切换请求会被拒绝 关闭 功能整体禁用,on/off 都会被拒绝 状态判定只看活文件(basename 为 opencode.json 的那份)里 mcp.servers 是否非空, 与 mcpcfg.txt「含MCP的配置」/「安静配置」两键的行语义无关,因此对调之后身份漂移也不影响判断。 """ import argparse import base64 import datetime import json import os import re import sys import urllib.error import urllib.request from pathlib import Path # ---------------------------------------------------------------- 控制台编码 def _fix_console() -> None: for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] except Exception: pass _fix_console() MODE_LABEL = {"auto": "自动", "manual": "手动", "off": "关闭"} LABEL_MODE = { "自动": "auto", "auto": "auto", "audo": "auto", # 用户手误拼写,保持与插件一致 "手动": "manual", "manual": "manual", "hand": "manual", "on": "manual", "关闭": "off", "off": "off", "disable": "off", "disabled": "off", } DEFAULT_DISTRO = os.environ.get("OC_SKILL_DISTRO", "Ubuntu-24.04") DEFAULT_PORT = int(os.environ.get("OC_PORT", "4096")) # ---------------------------------------------------------------- 路径工具 def _basename(path: str) -> str: """跨平台取文件名:同时认 / 与 \\,避免在 Windows 上解析 WSL 路径出错。""" return re.split(r"[\\/]", path.strip())[-1] def _home_config() -> Path: return Path.home() / ".config" / "opencode" def wsl_config_dir(distro: str) -> Path: if os.name == "nt": return Path(f"\\\\wsl.localhost\\{distro}\\root\\.config\\opencode") return Path("/root/.config/opencode") def resolve_cfg_file(target: str, distro: str) -> Path: if target == "wsl": return wsl_config_dir(distro) / "mcpcfg.txt" return _home_config() / "mcpcfg.txt" # ---------------------------------------------------------------- mcpcfg 解析 # 与 tui.js / ai-mcp.ts 的 readMcpCfg 保持一致: # 优先按键值解析(模式: / 含MCP的配置: / 安静配置:,认中英文冒号), # 解析不到再回退旧「裸三行」(第1行模式、第2行含MCP、第3行安静)。 def read_cfg(path: Path) -> dict: if not path.exists(): return { "mode": "off", "error": f"缺少配置文件:{path}(配置缺失时功能视为关闭)", } try: raw_text = path.read_text(encoding="utf-8", errors="replace") except Exception as exc: return {"mode": "off", "error": f"读取 {path} 失败:{exc}"} lines = raw_text.splitlines() kv: dict[str, str] = {} for ln in lines: line = ln.strip() if not line or line.startswith("#") or line.startswith("//"): continue m = re.match(r"^([^::]+)[::]\s*(.*)$", line) if m: kv[m.group(1).strip().lower()] = m.group(2).strip() mode_val = kv.get("模式", kv.get("mode", kv.get("第1行"))) on_val = kv.get("含mcp的配置", kv.get("含mcp配置", kv.get("含mcp", kv.get("on", kv.get("原始配置", kv.get("第2行")))))) quiet_val = kv.get("安静配置", kv.get("安静", kv.get("quiet", kv.get("备份", kv.get("backup", kv.get("第3行")))))) is_legacy = mode_val is None and on_val is None and quiet_val is None if not is_legacy: mode = LABEL_MODE.get((mode_val or "自动").strip().lower(), "auto") on_path = (on_val or "").strip() quiet_path = (quiet_val or "").strip() else: bare = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("//")] mode = LABEL_MODE.get((bare[0] if bare else "").strip().lower(), "auto" if not bare or bare[0] == "" else "auto") on_path = (bare[1] if len(bare) > 1 else "").strip() quiet_path = (bare[2] if len(bare) > 2 else "").strip() active = shadow = None if _basename(on_path) == "opencode.json": active, shadow = on_path, quiet_path elif _basename(quiet_path) == "opencode.json": active, shadow = quiet_path, on_path result = { "mode": mode, "on_path": on_path, "quiet_path": quiet_path, "active": active, "shadow": shadow, "legacy": is_legacy, "error": None, } if mode != "off" and not active: result["error"] = "mcpcfg.txt 两行路径都不是 opencode.json(「含MCP的配置」或「安静配置」需指向 opencode.json)" elif mode != "off" and (not Path(on_path).exists() or not Path(quiet_path).exists()): result["error"] = "mcpcfg.txt 指向的配置文件不存在(请检查「含MCP的配置」/「安静配置」两键的路径)" return result def write_cfg(path: Path, mode: str, on_path: str, quiet_path: str) -> None: """按键值格式写回(模式/含MCP的配置/安静配置),带注释头。""" mode_cn = {"auto": "自动", "manual": "手动", "off": "关闭"}.get(mode, "自动") body = "\n".join([ "# MCP 开关配置:改完保存,重启 OC 生效(第1个键=模式)", "# 模式可填:自动(大模型可切换) / 手动(仅 /mcpon /mcpoff) / 关闭(禁用本功能)", f"模式: {mode_cn}", f"含MCP的配置: {on_path}", f"安静配置: {quiet_path}", "", ]) path.write_text(body, encoding="utf-8") def set_mode_in_file(path: Path, mode: str) -> None: """只改模式键:键值文件按键替换;旧三行文件替换第1行(插件下次读会自动迁移)。""" raw = path.read_text(encoding="utf-8", errors="replace") lines = raw.splitlines() mode_cn = {"auto": "自动", "manual": "手动", "off": "关闭"}.get(mode, "自动") kv_pattern = re.compile(r"^(模式|mode)\s*[::]\s*.*$", re.IGNORECASE) hit = False for i, ln in enumerate(lines): if kv_pattern.match(ln.strip()): prefix = ":" if "模式" in ln.split(":")[0] or ":" in ln else ":" lines[i] = f"模式{prefix} {mode_cn}" hit = True break if not hit: # 旧三行(或键缺失):第1行位放模式;若本来是键值缺模式键,插到最前面 if any(re.match(r"^([^::]+)[::]", l.strip()) for l in lines if l.strip() and not l.strip().startswith("#")): lines.insert(0, f"模式: {mode_cn}") else: while len(lines) < 3: lines.append("") lines[0] = mode_cn path.write_text("\n".join(lines) + "\n", encoding="utf-8") def flag_path(cfg_dir_target: str, distro: str) -> Path: """显式开启标记 mcp.on.flag 的落点(与插件共用;wsl 目标落在 WSL 目录)。""" base = wsl_config_dir(distro) if cfg_dir_target == "wsl" else _home_config() return base / "mcp.on.flag" def set_on_flag(cfg_dir_target: str, distro: str, on: bool) -> None: try: p = flag_path(cfg_dir_target, distro) if on: p.write_text(str(os.getpid()), encoding="utf-8") elif p.exists(): p.unlink() except Exception: pass def active_has_mcp(active: str | None) -> bool: try: if not active: return False data = json.loads(Path(active).read_text(encoding="utf-8")) servers = (data.get("mcp") or {}).get("servers") or {} return bool(servers) except Exception: return False def swap_files(a: str, b: str) -> None: pa, pb = Path(a), Path(b) ca, cb = pa.read_bytes(), pb.read_bytes() pa.write_bytes(cb) pb.write_bytes(ca) # ---------------------------------------------------------------- reload def _read_password(cfg_dir: Path) -> str: try: data = json.loads((cfg_dir / "opencode.json").read_text(encoding="utf-8")) pwd = (data.get("server") or {}).get("password") return str(pwd) if pwd else "" except Exception: return "" def _wsl_ip(distro: str) -> str: if os.name != "nt": return "" try: import subprocess out = subprocess.run( ["wsl.exe", "-d", distro, "--", "bash", "-lc", "hostname -I"], capture_output=True, timeout=15, ) for tok in (out.stdout or b"").decode("utf-8", "ignore").split(): if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", tok): return tok except Exception: pass return "" # 关键:本机/WSL 的 OC 绝不能走系统代理。 # 环境里若有 HTTP_PROXY/HTTPS_PROXY,urlopen 会把 localhost 请求丢给代理, # 结果是代理回 502 —— 看起来像 OC 挂了,其实是被劫走了。 _NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) if os.environ.get("MCPCTL_USE_PROXY"): _NO_PROXY_OPENER = urllib.request.build_opener() def _post(url: str, password: str, timeout: float) -> tuple[int, str]: req = urllib.request.Request(url, method="POST", data=b"{}") req.add_header("content-type", "application/json") req.add_header("accept", "application/json") if password: req.add_header("authorization", f"Bearer {password}") try: with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp: return resp.status, resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as exc: if exc.code == 401 and password: raw = base64.b64encode(f":{password}".encode()).decode("ascii") req2 = urllib.request.Request(url, method="POST", data=b"{}") req2.add_header("content-type", "application/json") req2.add_header("authorization", f"Basic {raw}") try: with _NO_PROXY_OPENER.open(req2, timeout=timeout) as resp: return resp.status, resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as exc2: return exc2.code, str(exc2) return exc.code, str(exc) except Exception as exc: return 0, str(exc) def do_reload(target: str, args) -> tuple[bool, str]: password = args.password or _read_password( wsl_config_dir(args.distro) if target == "wsl" else _home_config() ) hosts = [f"http://localhost:{args.port}", f"http://127.0.0.1:{args.port}"] if target == "wsl" and os.name == "nt": ip = _wsl_ip(args.distro) if ip: hosts.append(f"http://{ip}:{args.port}") last = "" for host in hosts: code, body = _post(host + "/api/location/reload", password, args.timeout) if 200 <= code < 300: return True, f"热重载成功({host}/api/location/reload)" if code in (401, 403): last = f"{host} 鉴权失败(HTTP {code}),请用 --password 指定 server.password" elif code == 404: last = f"{host} 未提供 /api/location/reload(可能需要重启 OC 生效)" elif code: last = f"{host} 返回 HTTP {code}:{body[:200]}" else: last = f"{host} 连不上(OC 可能没在运行)" return False, last # ---------------------------------------------------------------- 子命令 def _stamp() -> str: return datetime.datetime.now().strftime("%Y%m%d%H%M%S") def print_state(cfg: dict) -> bool: has = active_has_mcp(cfg.get("active")) print(f"模式:{MODE_LABEL.get(cfg['mode'], cfg['mode'])}(mcpcfg.txt「模式」键)") print(f"MCP :{'已开启(配置含 mcp.servers)' if has else '已关闭(安静,无 mcp 配置)'}") print(f"活配置:{cfg.get('active') or '(未识别)'}") if cfg.get("error"): print(f"警告:{cfg['error']}") return has def cmd_status(args) -> int: path = resolve_cfg_file(args.target, args.distro) print(f"配置文件:{path}") cfg = read_cfg(path) print_state(cfg) return 0 if not cfg.get("error") else 2 def _switch(args, want_on: bool) -> int: path = resolve_cfg_file(args.target, args.distro) print(f"配置文件:{path}") cfg = read_cfg(path) if cfg.get("error"): print(f"失败:{cfg['error']}") return 2 action = "开启" if want_on else "关闭" if cfg["mode"] == "off" and not args.ignore_mode: print("拒绝:MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。") print("如需使用,请把模式改成「自动」或「手动」。") return 3 has = active_has_mcp(cfg["active"]) if has == want_on: print(f"无需切换:MCP 已是{'开启' if want_on else '关闭(安静)'}状态。") print_state(cfg) return 0 swap_files(cfg["on_path"], cfg["quiet_path"]) after = active_has_mcp(cfg["active"]) if after != want_on: print("失败:配置对调后状态校验不符,请检查 mcpcfg.txt「含MCP的配置」/「安静配置」路径。") return 4 # 显式意图落标记:开写 mcp.on.flag、关删标记(与 tui.js / ai-mcp 共用, # 避免自动模式的“启动归位”把刚开启的状态又换回去) set_on_flag(args.target, args.distro, want_on) print(f"配置已对调,MCP 现在是「{'开启' if after else '关闭'}」。") if args.no_reload: print("已按 --no-reload 跳过热重载,需重启 OC 生效。") return 0 ok, msg = do_reload(args.target, args) if ok: print(f"{msg},MCP 配置立即生效。") return 0 print(f"热重载失败:{msg}") print("配置文件已经对调完成,重启 OpenCode 也能生效。") return 5 def cmd_on(args) -> int: return _switch(args, True) def cmd_off(args) -> int: return _switch(args, False) def cmd_mode(args) -> int: path = resolve_cfg_file(args.target, args.distro) if not path.exists(): print(f"配置文件不存在:{path}") return 2 if not args.value: cfg = read_cfg(path) cur = MODE_LABEL.get(cfg.get("mode", "auto"), cfg.get("mode", "auto")) print(f"当前模式:{cur}") print("可选值:自动 / 手动 / 关闭(也支持 auto / manual / off)") return 0 key = args.value.strip().lower() if key not in LABEL_MODE: print(f"无法识别的模式:{args.value}(可选:自动 / 手动 / 关闭 / auto / manual / off)") return 2 set_mode_in_file(path, LABEL_MODE[key]) print(f"已把模式设为:{MODE_LABEL[LABEL_MODE[key]]}({path})") return 0 def cmd_backup(args) -> int: path = resolve_cfg_file(args.target, args.distro) if not path.exists(): print(f"配置文件不存在:{path}") return 2 bak = path.with_name(path.name + f".bak-{_stamp()}") bak.write_bytes(path.read_bytes()) print(f"已备份:{bak}") return 0 # ---------------------------------------------------------------- 入口 def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="mcpctl", description="MCP 配置对调开关(status / on / off / mode),与插件共用同一份 mcpcfg.txt", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "示例:\n" " python mcpctl.py status\n" " python mcpctl.py on\n" " python mcpctl.py off --no-reload\n" " python mcpctl.py mode 手动\n" " python mcpctl.py status --target wsl\n" ), ) p.add_argument("--target", choices=["local", "wsl"], default="local", help="操作本机还是 WSL 的配置(默认 local)") p.add_argument("--distro", default=DEFAULT_DISTRO, help=f"WSL 发行版名(默认 {DEFAULT_DISTRO})") p.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"OC 端口(默认 {DEFAULT_PORT})") p.add_argument("--timeout", type=float, default=5.0, help="HTTP 超时秒数") p.add_argument("--password", default="", help="OC server.password(默认自动从 opencode.json 读)") p.add_argument("--no-reload", action="store_true", help="只对调文件,不调用热重载") p.add_argument("--ignore-mode", action="store_true", help="忽略「模式: 关闭」限制强制切换(慎用)") sub = p.add_subparsers(dest="cmd") sp = sub.add_parser("status", help="查看当前模式与 MCP 开关状态") sp.set_defaults(func=cmd_status) sp = sub.add_parser("on", help="对调为含 MCP 的配置并热重载") sp.set_defaults(func=cmd_on) sp = sub.add_parser("off", help="对调为安静配置(删 MCP)并热重载") sp.set_defaults(func=cmd_off) sp = sub.add_parser("mode", help="查看或修改 mcpcfg.txt「模式」键(自动/手动/关闭)") sp.add_argument("value", nargs="?", help="要设置的模式:自动 / 手动 / 关闭(不传则只查看)") sp.set_defaults(func=cmd_mode) sp = sub.add_parser("backup", help="备份当前 mcpcfg.txt") sp.set_defaults(func=cmd_backup) return p def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "cmd", None): parser.print_help() return 0 try: return args.func(args) except KeyboardInterrupt: print("\n已取消。") return 130 if __name__ == "__main__": sys.exit(main())