From 2208ad0f7cf52539983064645bcda4de03267336 Mon Sep 17 00:00:00 2001 From: ypshidifu Date: Wed, 23 Sep 2026 17:50:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=B5=B0=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E5=8F=91=E7=8E=B0+GET=E6=8E=A2=E6=B4=BB+experimental=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=EF=BC=9BWSL=E8=AF=BB=E6=93=8D=E4=BD=9C=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=94=A4=E9=86=92=EF=BC=9BCLI=E5=8A=A0/home=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E4=BD=8D=E7=BD=AE=E5=9B=9E=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 46 +++++++--- mcpctl.py | 247 ++++++++++++++++++++++++++++++++++++++++------------- oc.py | 41 +++++++-- ocskill.py | 122 +++++++++++++++++++++----- 4 files changed, 355 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 765bd08..1da052a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ - `1` / `技能管理` → 列表(1)、推送(2)、拉取(3)、目录(4);每层 `0` 返回 - `2` / `连接器管理` → 状态(1)、开启(2)、关闭(3)、模式(4)、备份(5)、`t` 切换本机/WSL +- **任意位置输入 `/home`(或「首页 / 返回首页 / 回首页 / 回主页 / 主菜单 / 主页」)直接跳回顶层菜单**—— + 菜单位、参数位(路径/技能名/新模式 的输入框)、回车前都算,不用一层层按 `0`; + 在顶层菜单输 `/home` 会提示「已经在首页了」 - 菜单内所有操作会逐项问参数(路径、是否覆盖…),回车即用默认值 - 旧的 6 个分散 bat(skill-list / skill-push / …)已合并删除,功能全部并入本菜单 @@ -91,7 +94,7 @@ python ocskill.py dirs | `--distro` | `Ubuntu-24.04` | WSL 发行版名 | | `--url` | 自动探测 | 显式指定 OC 地址,如 `http://localhost:4096` | | `--port` | `4096` | OC 端口(等价于环境变量 `OC_PORT`) | -| `--password` | 自动读 | OC 的 `server.password`,先试 Bearer、401 再试 Basic | +| `--password` | 自动读 | OC 口令,读取链 `service.json` → 环境变量 `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD` → `opencode.json server.password`;鉴权统一 `Basic base64("opencode:<口令>")`(用户名固定 `opencode`)。端口 4096 没监听时会 `tasklist`+`netstat` 自动发现 OC 实际随机端口并探活 | ### 2. `mcpctl.py` — MCP 配置对调开关 @@ -136,10 +139,20 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载 失败时配置文件已经对调完成,重启 OC 一样生效。 **开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前, -先把活配置里的 `mcp` 用 `PATCH /config` 推进**运行中的 OC**(三连尝试:SDK -`client.config.update({config})` → `({body})` → 原生 fetch)。推送成功再 reload -= 双保险,MCP 百分百加载;推送失败不阻断,自动降级为原 reload 单通道。 -三个入口(TUI 插件 `/mcpon`、server 插件 `mcp_switch`、本 CLI `mcpctl on`)行为一致。 +先把活配置里的 `mcp` 推进**运行中的 OC**。三端同一套语义——**GET+merge 补 shell +→ PATCH 真实路由**,按各自运行方式实现: + +- **TUI 插件(v10,进程内 SDK)**:`client.config.get()` 取 shell → + `config.update({config})` → `({body})` → `experimental.config.update` 三跳; +- **server 插件(v3,raw HTTP)**:`GET /api/config?directory=` 取 shell → + `PATCH /api/experimental/config?directory=`,口令读 `service.json` 链、 + `Basic base64("opencode:<口令>")`; +- **本 CLI(mcpctl)**:`tasklist`+`netstat` **端口发现**(OC 每次启动随机端口, + 写死 4096 常 10061)→ `GET /api/config` 探活并取 `info.shell`(GET→活文件→null + 三级兜底)→ `PATCH /api/experimental/config?directory=`,body 必含 `shell` + (缺则 400 `Missing key at ["shell"]`;裸 `PATCH /config` 是 405)。 + +推送成功再 reload = 双保险,MCP 百分百加载;推送失败不阻断,自动降级为原 reload 单通道。 ### 稳定性设计(WSL 相关) @@ -147,12 +160,14 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载 | --- | --- | | WSL 路径假错「两键路径不存在 / mcpcfg.txt 不存在」 | mcpcfg 内部是 POSIX 路径(`/root/...`),Windows 上曾被解析成 `C:\root`。现在统一过 `fs_view()` 翻译成 `\\wsl.localhost\<发行版>\...` 再访问;报错与展示仍用**原文**(你在 txt 里看到的样子) | | 参数顺序被 argparse 拒(`mcpctl status --target wsl` 报 unrecognized) | `_hoist_shared()` 在解析前把共享 flag(`--target/--distro/--port/--timeout/--password/--no-reload/--ignore-mode`)提到最前,**子命令前后都能写** | -| WSL 没跑时读配置卡死/假错 | `wsl_gate` 统一门控:**读操作**(status / mode 查看 / backup)VM 没跑秒回提示;**写操作**(on/off/mode 设置 / push / pull)显式打印「正在唤醒」后再拉起 VM(超时 45s),唤醒失败给清晰指引 | +| WSL 没跑时读配置卡死/假错 | `wsl_gate` 统一门控:**读写都先唤醒**——「先给机器启动起来,再去读配置」:status / mode 查看 / backup / `list` / `dirs` 等读操作,VM 没跑同样打印「正在唤醒」拉起 VM(超时 45s)再读,绝不秒回假错;唤醒失败给清晰指引 | | `wsl.exe` 超时后进程永久卡死(必须 ctrl+C) | 所有 `wsl.exe` 调用改走 `_wsl_safe_run`:`stdin=DEVNULL` + stdout/stderr 落**临时文件**(零管道)+ 先查 `-l -q --running` 绝不无提示冷启动。根因是管道句柄被 wslrelay 等后代继承,client 被 kill 后 `communicate()` 等 EOF 永不返回 | | 菜单被子命令 `raise SystemExit` 杀死 | `oc.py` 的 `run_safe()` 护栏:`KeyboardInterrupt` → 已取消回菜单;`SystemExit` → 报告信息回菜单;异常 → 打印错误回菜单,菜单本体永不退出 | -`list` / `dirs` 连接 OC 前会先打印「正在探测 OpenCode API 连接…」进度行(flush), -几秒无响应时一眼看出是在探测而不是死了。 +`list` / `dirs` 连接 OC 前会先打印「正在唤醒以便读配置…」(VM 没跑时)和 +「正在探测 OpenCode API 连接…」进度行(flush), +几秒无响应时一眼看出是在唤醒/探测而不是死了;`list` 唤醒失败**绝不**回落到 +UNC 目录扫描(那会触发冷启动卡死),只走 API 或明确报错。 --- @@ -168,10 +183,17 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载 其余排查: 1. 确认 WSL 里的 OC 起了:`opencode serve --port 4096` -2. 端口不对就加 `--port`,或设环境变量 `OC_PORT` -3. 提示 `401 WWW-Authenticate: Basic realm="Secure Area"` → OC 启用了服务密码。 - 用 `--password` 传值(默认自动从 `opencode.json` 的 `server.password` 读; - 若该字段为空但 OC 仍要 401,说明它用了随机密码,需启动时设 `OPENCODE_SERVER_PASSWORD`) +2. **端口不对不用管**:OC 每次启动端口随机(实测如 49374),工具会 `tasklist` + 找 `opencode.exe` 的 PID、`netstat -ano` 找它 LISTENING 的端口, + 再逐候选 `GET /api/config` 探活(200 = 命中);仍要显式指定就加 `--port` + 或环境变量 `OC_PORT` +3. 提示 `401 WWW-Authenticate: Basic realm="Secure Area"` → OC 启用了服务口令。 + 用 `--password` 传值(默认按 `service.json` → 环境变量 + `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD` → `opencode.json server.password` + 的顺序读,用户名固定 `opencode`、Basic 鉴权)。注意临时 + `opencode serve --port X` 实例用**每次启动随机口令且不落盘**,`service.json` + 读到的口令对它必 401——要么用 TUI 启动的 OC(走 `service.json`),要么 + `--password` 传启动时设的口令 4. `localhost` 不通 → 工具会自动 `wsl hostname -I` 取 WSL 真实 IP 再试 5. 全都失败 → `list` 会自动降级为**直接扫目录**,照样能看到 skill 列表 diff --git a/mcpctl.py b/mcpctl.py index f93fbbc..ada9e52 100644 --- a/mcpctl.py +++ b/mcpctl.py @@ -26,6 +26,7 @@ import os import re import sys import urllib.error +import urllib.parse import urllib.request from pathlib import Path @@ -138,24 +139,22 @@ def fs_view(p: str, target: str, distro: str) -> str: def wsl_gate(args, need_write: bool = False) -> str | None: """WSL 目标统一门(返回 None=可继续;返回字符串=错误信息): - - 读操作(status/mode查看/backup):VM 没跑 → 秒回清晰提示,绝不撞 UNC 冷启动 - - 写操作(on/off/mode设置):VM 没跑 → 显式提示后唤醒,唤醒失败才报错""" + - 读操作(status/mode查看/backup):VM 没跑 → 先自动唤醒再读,不再只提示 + ——「先给机器启动起来,再去读配置」;唤醒失败才报错 + - 写操作(on/off/mode设置):同样唤醒(语义一致) + 全程走 _wsl_safe_run(零管道),唤醒用 -- true 轻量拉起 VM。""" if getattr(args, "target", "local") != "wsl" or os.name != "nt": return None if _wsl_running(args.distro): return None - if not need_write: - return ( - f"WSL({args.distro})未运行,读不到它的配置。" - f"先启动:wsl.exe -d {args.distro}" - ) - print(f"WSL({args.distro})未运行,正在唤醒(可能需要十几秒)...", flush=True) + verb = "读配置" if not need_write else "写配置" + print(f"WSL({args.distro})未运行,正在唤醒以便{verb}(可能需要十几秒)...", flush=True) _wsl_safe_run(["wsl.exe", "-d", args.distro, "--", "true"], timeout=45) if _wsl_running(args.distro): print("WSL 已就绪。", flush=True) return None return ( - f"WSL({args.distro})唤醒失败,无法访问其配置。" + f"WSL({args.distro})唤醒失败,无法{verb}。" f"请手动执行 wsl.exe -d {args.distro} 启动后重试。" ) @@ -320,6 +319,20 @@ def swap_files(a: str, b: str) -> None: # ---------------------------------------------------------------- reload def _read_password(cfg_dir: Path) -> str: + """口令读取链(与 ocskill._read_server_password 一致): + service.json(cli.service-config 真实口令,用户名固定 opencode) + → 环境变量 OPENCODE_PASSWORD ?? OPENCODE_SERVER_PASSWORD + → opencode.json 的 server.password(legacy,OC 会忽略,末选)。""" + try: + data = json.loads((cfg_dir / "service.json").read_text(encoding="utf-8")) + pwd = data.get("password") + if pwd: + return str(pwd) + except Exception: + pass + env = os.environ.get("OPENCODE_PASSWORD") or os.environ.get("OPENCODE_SERVER_PASSWORD") + if env: + return env try: data = json.loads((cfg_dir / "opencode.json").read_text(encoding="utf-8")) pwd = (data.get("server") or {}).get("password") @@ -357,62 +370,149 @@ def _post( url: str, password: str, timeout: float, - data: bytes = b"{}", + data: bytes | None = b"{}", method: str = "POST", ) -> tuple[int, str]: + """发请求并返回 (status, body)。 + 鉴权头 = Basic base64("opencode:" + service.json 口令)——用户名必须是 + opencode(试过 x/1/admin/root 全 401),这是 OC server 的固定约定。""" req = urllib.request.Request(url, method=method, data=data) req.add_header("content-type", "application/json") req.add_header("accept", "application/json") if password: - req.add_header("authorization", f"Bearer {password}") + raw = base64.b64encode(f"opencode:{password}".encode("utf-8")).decode("ascii") + req.add_header("authorization", f"Basic {raw}") 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=method, data=data) - 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) + return exc.code, (exc.read().decode("utf-8", "replace") if hasattr(exc, "read") else str(exc)) except Exception as exc: return 0, str(exc) +# ---------------------------------------------------------------- 端口发现 + 探活 +def _discover_oc_ports() -> list[int]: + """端口发现:tasklist 找 opencode.exe 的 PID,netstat -ano 找这些 PID 的 + LISTENING TCP 端口。OC 每次启动端口随机(实测 49374),写死 4096 必 10061。 + 任何一步失败都返回 [](回退默认端口),绝不抛异常。与 ocskill 逐字一致。""" + if os.name != "nt": + return [] + import subprocess + + try: + cr = getattr(subprocess, "CREATE_NO_WINDOW", 0) + tl = subprocess.run( + ["tasklist", "/fo", "csv", "/nh"], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=10, creationflags=cr, + ).stdout.decode("utf-8", "ignore") + except Exception: + return [] + pids = set() + for line in tl.splitlines(): + cols = re.findall(r'"([^"]*)"', line) # "opencode.exe","13748","Console",... + if len(cols) >= 2 and cols[0].lower() == "opencode.exe" and cols[1].isdigit(): + pids.add(cols[1]) + if not pids: + return [] + try: + ns = subprocess.run( + ["netstat", "-ano"], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=10, creationflags=cr, + ).stdout.decode("utf-8", "ignore") + except Exception: + return [] + ports: list[int] = [] + for line in ns.splitlines(): + cols = line.split() + if len(cols) >= 5 and cols[0].upper() == "TCP" and cols[3].upper() == "LISTENING" and cols[4] in pids: + lp = cols[1].rsplit(":", 1)[-1] + if lp.isdigit(): + p = int(lp) + if p not in ports: + ports.append(p) + return ports + + +def _cfg_directory(target: str, distro: str) -> str: + """GET/PATCH 的 directory 参数:OC 自己视角的配置目录。""" + if target == "wsl": + return "/root/.config/opencode" + return str(_home_config()).replace("\\", "/") + + +def _candidate_hosts(target: str, args) -> list[str]: + hosts = [f"http://localhost:{args.port}", f"http://127.0.0.1:{args.port}"] + if target == "wsl": + # WSL 目标:只认 4096 与 WSL IP(netstat 只看得见 Windows 进程, + # 别把发现到的本机 OC 端口误命中成 WSL 的) + if os.name == "nt": + ip = _wsl_ip(args.distro) + if ip: + hosts.append(f"http://{ip}:{args.port}") + else: + # 本机目标:4096 没监听时,补上 opencode.exe 实际 LISTENING 的端口 + for p in _discover_oc_ports(): + hosts.append(f"http://localhost:{p}") + hosts.append(f"http://127.0.0.1:{p}") + seen, out = set(), [] + for h in hosts: + if h not in seen: + seen.add(h) + out.append(h) + return out + + +def _find_live_host(target: str, args, password: str) -> tuple[str, str, str]: + """逐候选 GET /api/config?directory=... 探活;200 = 命中运行中的 OC。 + 返回 (host, directory, 错误信息);host 空串 = 全部未命中。""" + directory = _cfg_directory(target, args.distro) + encoded = urllib.parse.quote(directory, safe=":/") + last = "" + for host in _candidate_hosts(target, args): + code, body = _post( + host + "/api/config?directory=" + encoded, + password, args.timeout, data=None, method="GET", + ) + if code == 200: + return host, directory, "" + if code in (401, 403): + last = f"{host} 鉴权失败(HTTP {code}),请用 --password 指定口令(默认读 service.json)" + elif code: + last = f"{host} 探活返回 HTTP {code}:{body[:160]}" + else: + last = f"{host} 连不上({body[:80]})" + return "", directory, last or "没有找到运行中的 OC(4096 未监听,端口发现也未命中)" + + 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 + # 端口发现 + 探活:先找到真正在跑的 OC(4096 是默认值,实测常是随机端口) + host, _dir, why = _find_live_host(target, args, password) + if not host: + return False, why + 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): + return False, f"{host} 鉴权失败(HTTP {code}),请用 --password 指定口令" + if code == 404: + return False, f"{host} 未提供 /api/location/reload(可能需要重启 OC 生效)" + if code: + return False, f"{host} 返回 HTTP {code}:{body[:200]}" + return False, f"{host} 连不上(OC 可能没在运行)" def push_mcp_config(args) -> tuple[bool, str]: """reload 之前的兜底:把活配置里的 mcp 直接 PATCH 进运行中的 OC。 - 与 tui.js / ai-mcp.ts 的 pushMcpConfig 同一套链(SDK PATCH /config 无 /api 前缀)。 + 与 tui.js / ai-mcp.ts 的 pushMcpConfig 同一套链: + 端口发现 + GET /api/config?directory= 探活并取 shell(GET+merge 三级兜底) + → PATCH /api/experimental/config?directory=(body 必含 shell,缺则 400; + 裸 PATCH /config 是 405)。 只在「开启」方向推送;失败不阻断,继续走 reload 单通道。""" path = resolve_cfg_file(args.target, args.distro) cfg = read_cfg(path, args.target, args.distro) @@ -429,22 +529,51 @@ def push_mcp_config(args) -> tuple[bool, str]: password = args.password or _read_password( wsl_config_dir(args.distro) if args.target == "wsl" else _home_config() ) - hosts = [f"http://localhost:{args.port}", f"http://127.0.0.1:{args.port}"] - if args.target == "wsl" and os.name == "nt": - ip = _wsl_ip(args.distro) - if ip: - hosts.append(f"http://{ip}:{args.port}") + # 1) 端口发现 + 探活:GET /api/config 200 = 命中运行中的 OC(4096 常无监听) + host, directory, why = _find_live_host(args.target, args, password) + if not host: + return False, why + encoded = urllib.parse.quote(directory, safe=":/") - body = json.dumps({"mcp": mcp}).encode("utf-8") - last = "" - for host in hosts: - code, resp = _post( - host + "/config", password, args.timeout, data=body, method="PATCH" - ) - if 200 <= code < 300: - return True, f"已推送({host}/config)" - last = f"{host} HTTP {code}:{resp[:160]}" - return False, last + # 2) GET+merge 补 shell:GET 运行中 config 的 info.shell → 活文件 shell → null + shell = None + gcode, gbody = _post( + host + "/api/config?directory=" + encoded, + password, args.timeout, data=None, method="GET", + ) + if gcode == 200: + try: + got = json.loads(gbody.decode("utf-8", errors="replace")) + info = None + if isinstance(got, list): + doc = next( + (d for d in got + if isinstance(d, dict) and d.get("type") == "document" and d.get("info")), + None, + ) or next((d for d in got if isinstance(d, dict) and d.get("info")), None) + info = doc.get("info") if doc else None + elif isinstance(got, dict): + info = got.get("info") if isinstance(got.get("info"), dict) else got + if isinstance(info, dict) and "shell" in info: + shell = info["shell"] + except Exception: + pass + if shell is None and "shell" in data: + shell = data.get("shell") + if not (shell is None or isinstance(shell, str)): + shell = None + + # 3) PATCH 真实路由(必带 directory 与 shell) + payload = json.dumps({"shell": shell, "mcp": mcp}).encode("utf-8") + code, resp = _post( + host + "/api/experimental/config?directory=" + encoded, + password, args.timeout, data=payload, method="PATCH", + ) + if 200 <= code < 300: + return True, f"已推送({host}/api/experimental/config,shell={'null' if shell is None else '已带'})" + if code in (401, 403): + return False, f"{host} 鉴权失败(HTTP {code}),请用 --password 指定口令(默认读 service.json)" + return False, f"{host} PATCH HTTP {code}:{resp[:200]}" # ---------------------------------------------------------------- 子命令 @@ -649,7 +778,7 @@ def build_parser() -> argparse.ArgumentParser: 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("--password", default="", help="OC 口令(默认按 service.json → 环境变量 → opencode.json 顺序自动读)") p.add_argument("--no-reload", action="store_true", help="只对调文件,不调用热重载") p.add_argument("--ignore-mode", action="store_true", help="忽略「模式: 关闭」限制强制切换(慎用)") diff --git a/oc.py b/oc.py index 7ca2599..322e259 100644 --- a/oc.py +++ b/oc.py @@ -76,12 +76,23 @@ MCP_ALIASES = { } +# /home 别名集:任意子菜单输入即直接跳回首页(顶层菜单),不再一层层按 0 返回 +HOME_KEYS = {"/home", "home", "首页", "返回首页", "回首页", "回主页", "主菜单", "主页"} + + +class GoHome(Exception): + """在任意位置输入 /home(或「首页」等)—— 一路抛到顶层菜单重绘首页。""" + + def ask(prompt: str) -> str: try: - return input(prompt) + raw = input(prompt) except (EOFError, KeyboardInterrupt): print() return "0" + if _norm(raw) in HOME_KEYS: + raise GoHome() + return raw def match(raw: str, aliases: dict, default: str | None = None) -> str | None: @@ -97,9 +108,12 @@ def match(raw: str, aliases: dict, default: str | None = None) -> str | None: def pause(msg: str = "按回车返回…") -> None: try: - input(msg) + raw = input(msg) except (EOFError, KeyboardInterrupt): print() + return + if _norm(raw) in HOME_KEYS: + raise GoHome() def banner(title: str) -> None: @@ -145,7 +159,8 @@ def menu_skill(shared: dict) -> None: print(" 2. 推送技能到 OC(zip / 目录 / SKILL.md / base64)") print(" 3. 拉取技能到本机") print(" 4. 查看技能目录与连接状态") - raw = ask("请选择(编号或中文): ") + print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单") + raw = ask("请选择(编号或中文,/home 回首页): ") act = match(raw, SKILL_ALIASES) if act is None: print(" 没看懂「%s」,输入 0-4 或中文,如:列表 / 推送 / 拉取" % raw.strip()) @@ -195,7 +210,8 @@ def menu_mcp(shared: dict) -> None: print(" 4. 查看 / 修改模式(自动 / 手动 / 关闭)") print(" 5. 备份当前 mcpcfg.txt") print(" t. 切换目标(本机 <-> WSL)") - raw = ask("请选择(编号或中文): ") + print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单") + raw = ask("请选择(编号或中文,/home 回首页): ") key = _norm(raw) if key in ("t", "切换", "target", "目标"): target = "wsl" if target == "local" else "local" @@ -232,7 +248,12 @@ def menu_top() -> None: print(" 0. 退出") print(" 1. 技能管理(skill 拉取 / 推送)") print(" 2. 连接器管理(MCP 开关 / 模式)") - raw = ask("请选择: ") + print(" 提示:子菜单任意位置输入 /home 或「返回首页」可直接回到本菜单") + try: + raw = ask("请选择: ") + except GoHome: + print(" 已经在首页了") + continue act = match(raw, TOP_ALIASES) if act is None: print(" 没看懂「%s」,输入 0 / 1 / 2,或:退出 / 技能管理 / 连接器管理" % raw.strip()) @@ -241,9 +262,15 @@ def menu_top() -> None: print("再见。") return if act == "skill": - menu_skill(shared) + try: + menu_skill(shared) + except GoHome: + print(" 已返回首页") elif act == "mcp": - menu_mcp(shared) + try: + menu_mcp(shared) + except GoHome: + print(" 已返回首页") # ---------------------------------------------------------------- 直传模式 diff --git a/ocskill.py b/ocskill.py index d3e1c05..2a36bba 100644 --- a/ocskill.py +++ b/ocskill.py @@ -134,15 +134,83 @@ def _wsl_ensure_running(distro: str, purpose: str) -> str | None: ) -def _read_server_password(config_dir: Path) -> str: - """从 opencode.json 读 server.password(WSL 端配置里有这一项)。""" - cfg = config_dir / "opencode.json" +def _read_server_password(config_dir: Path, distro: str = "") -> str: + """口令读取链(与 mcpctl._read_password 一致): + service.json(cli.service-config 真实口令,用户名固定 opencode) + → 环境变量 OPENCODE_PASSWORD ?? OPENCODE_SERVER_PASSWORD + → opencode.json 的 server.password(legacy,OC 会忽略,末选)。 + config_dir 可能是 POSIX 的 /root/...(Windows 侧读不到),自动换 UNC 视图再试; + 本机 ~/.config/opencode 也作候选(本机 OC 的口令就存在那里)。""" + candidates = [config_dir] + s = str(config_dir) + if os.name == "nt" and s.startswith("/") and distro: + candidates.append(Path("\\\\wsl.localhost\\%s%s" % (distro, s.replace("/", "\\")))) + candidates.append(Path.home() / ".config" / "opencode") + + for d in candidates: + try: + data = json.loads((d / "service.json").read_text(encoding="utf-8")) + pwd = data.get("password") + if pwd: + return str(pwd) + except Exception: + pass + env = os.environ.get("OPENCODE_PASSWORD") or os.environ.get("OPENCODE_SERVER_PASSWORD") + if env: + return env + for d in candidates: + try: + data = json.loads((d / "opencode.json").read_text(encoding="utf-8")) + pwd = (data.get("server") or {}).get("password") + if pwd: + return str(pwd) + except Exception: + pass + return "" + + +def _discover_oc_ports() -> list[int]: + """端口发现:tasklist 找 opencode.exe 的 PID,netstat -ano 找这些 PID 的 + LISTENING TCP 端口。OC 每次启动端口随机(实测 49374),写死 4096 必 10061。 + 任何一步失败都返回 [](调用方回退默认端口),绝不抛异常。""" + if os.name != "nt": + return [] + import subprocess + try: - data = json.loads(cfg.read_text(encoding="utf-8")) - pwd = (data.get("server") or {}).get("password") - return str(pwd) if pwd else "" + cr = getattr(subprocess, "CREATE_NO_WINDOW", 0) + tl = subprocess.run( + ["tasklist", "/fo", "csv", "/nh"], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=10, creationflags=cr, + ).stdout.decode("utf-8", "ignore") except Exception: - return "" + return [] + pids = set() + for line in tl.splitlines(): + cols = re.findall(r'"([^"]*)"', line) # "opencode.exe","13748","Console",... + if len(cols) >= 2 and cols[0].lower() == "opencode.exe" and cols[1].isdigit(): + pids.add(cols[1]) + if not pids: + return [] + try: + ns = subprocess.run( + ["netstat", "-ano"], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=10, creationflags=cr, + ).stdout.decode("utf-8", "ignore") + except Exception: + return [] + ports: list[int] = [] + for line in ns.splitlines(): + cols = line.split() + if len(cols) >= 5 and cols[0].upper() == "TCP" and cols[3].upper() == "LISTENING" and cols[4] in pids: + lp = cols[1].rsplit(":", 1)[-1] + if lp.isdigit(): + p = int(lp) + if p not in ports: + ports.append(p) + return ports def _candidate_urls(explicit: str | None, distro: str, port: int) -> list[str]: @@ -152,6 +220,10 @@ def _candidate_urls(explicit: str | None, distro: str, port: int) -> list[str]: f"http://localhost:{port}", f"http://127.0.0.1:{port}", ] + # 端口发现:默认端口没监听时,补上 opencode.exe 实际 LISTENING 的端口 + for p in _discover_oc_ports(): + urls.append(f"http://localhost:{p}") + urls.append(f"http://127.0.0.1:{p}") # Windows 下再试 WSL 的真实 IP(localhost 转发失效时兜底) if os.name == "nt": ip = _wsl_ip(distro) @@ -185,8 +257,9 @@ def _build_request(url: str, password: str, method: str = "GET") -> urllib.reque req = urllib.request.Request(url, method=method) req.add_header("accept", "application/json") if password: - # OC 支持 Bearer 与 Basic 两种,先试 Bearer(401 时调用方会换 Basic 重试) - req.add_header("authorization", f"Bearer {password}") + # OC server 固定用 Basic,用户名必须是 opencode(x/1/admin/root 全 401) + raw = base64.b64encode(f"opencode:{password}".encode("utf-8")).decode("ascii") + req.add_header("authorization", f"Basic {raw}") return req @@ -199,21 +272,12 @@ if os.environ.get("OCSKILL_USE_PROXY"): def _try_fetch(url: str, password: str, timeout: float) -> tuple[int, bytes]: - """返回 (status, body)。401 时自动改用 Basic 重试一次。全程绕过系统代理。""" + """返回 (status, body)。全程绕过系统代理;鉴权头已是 Basic opencode:口令。""" req = _build_request(url, password) try: with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as exc: - if exc.code == 401 and password: - raw = base64.b64encode(f":{password}".encode("utf-8")).decode("ascii") - req2 = _build_request(url, "") - req2.add_header("authorization", f"Basic {raw}") - try: - with _NO_PROXY_OPENER.open(req2, timeout=timeout) as resp: - return resp.status, resp.read() - except urllib.error.HTTPError as exc2: - return exc2.code, exc2.read() return exc.code, (exc.read() if hasattr(exc, "read") else b"") except Exception: return 0, b"" @@ -224,7 +288,7 @@ def connect_oc(args) -> tuple[str | None, object]: 连接 OC,返回 (base_url, payload)。 依次尝试 /api/skill 与 /skill 两个已知端点;全失败则 payload=None。 """ - password = args.password or _read_server_password(Path(args.remote_config_dir)) + password = args.password or _read_server_password(Path(args.remote_config_dir), args.distro) last_err = "" for base in _candidate_urls(args.url, args.distro, args.port): for path in ("/api/skill", "/skill"): @@ -515,13 +579,21 @@ def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: # ---------------------------------------------------------------- 子命令 def cmd_list(args) -> int: - print("正在探测 OpenCode API 连接(约几秒;WSL 未运行会自动跳过 IP 探测)...", flush=True) + # 读操作也先唤醒 VM(“先给机器启动起来,再去读配置”); + # 唤醒失败 → 只走 API,绝不回落到 scan_skill_dir 的 UNC 冷启动 + gate_err = _wsl_ensure_running(args.distro, "list") + if gate_err: + print(gate_err) + print("正在探测 OpenCode API 连接(约几秒)...", flush=True) base, payload = connect_oc(args) root = wsl_skill_root(args.distro) if base: skills = extract_skills(payload) source = f"OpenCode API({base})" + elif gate_err: + skills = [] + source = f"(WSL 未运行且 API 未连上,跳过目录扫描){gate_err}" else: skills = scan_skill_dir(root) reason = payload if isinstance(payload, str) else "未知原因" @@ -627,6 +699,10 @@ def cmd_pull(args) -> int: def cmd_dirs(args) -> int: print(f"WSL skill 目录(push 目标):{wsl_skill_root(args.distro)}") print(f"本机 skill 目录(pull 默认落到当前目录):{WIN_LOCAL_ROOT}") + # 读操作也先唤醒 VM(“先给机器启动起来,再去读配置”) + gate_err = _wsl_ensure_running(args.distro, "dirs") + if gate_err: + print(gate_err) print("正在探测 OpenCode API 连接(约几秒;WSL 未运行会自动跳过 IP 探测)...", flush=True) base, _ = connect_oc(args) print(f"OpenCode API:{base or '未连接'}") @@ -688,11 +764,11 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--url", default=None, help="显式指定 OC 地址,如 http://localhost:4096") p.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"OC 端口(默认 {DEFAULT_PORT})") p.add_argument("--timeout", type=float, default=4.0, help="HTTP 超时秒数") - p.add_argument("--password", default="", help="OC server.password(默认自动从 opencode.json 读)") + p.add_argument("--password", default="", help="OC 口令(默认按 service.json → 环境变量 → opencode.json 顺序自动读)") p.add_argument( "--remote-config-dir", default="/root/.config/opencode", - help="读取 server.password 用的配置目录(Windows 侧用 WSL 内路径时才需要改)", + help="读取口令用的配置目录(Windows 侧会自动换 UNC 视图,一般不用改)", ) sub = p.add_subparsers(dest="cmd")