推送走端口发现+GET探活+experimental路由;WSL读操作自动唤醒;CLI加/home任意位置回首页
This commit is contained in:
@@ -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 列表
|
||||
|
||||
|
||||
@@ -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:
|
||||
# 端口发现 + 探活:先找到真正在跑的 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):
|
||||
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
|
||||
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:
|
||||
# 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 + "/config", password, args.timeout, data=body, method="PATCH"
|
||||
host + "/api/experimental/config?directory=" + encoded,
|
||||
password, args.timeout, data=payload, method="PATCH",
|
||||
)
|
||||
if 200 <= code < 300:
|
||||
return True, f"已推送({host}/config)"
|
||||
last = f"{host} HTTP {code}:{resp[:160]}"
|
||||
return False, last
|
||||
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="忽略「模式: 关闭」限制强制切换(慎用)")
|
||||
|
||||
|
||||
@@ -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 开关 / 模式)")
|
||||
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":
|
||||
try:
|
||||
menu_skill(shared)
|
||||
except GoHome:
|
||||
print(" 已返回首页")
|
||||
elif act == "mcp":
|
||||
try:
|
||||
menu_mcp(shared)
|
||||
except GoHome:
|
||||
print(" 已返回首页")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 直传模式
|
||||
|
||||
+98
-22
@@ -134,17 +134,85 @@ 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(cfg.read_text(encoding="utf-8"))
|
||||
pwd = (data.get("server") or {}).get("password")
|
||||
return str(pwd) if pwd else ""
|
||||
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:
|
||||
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 _candidate_urls(explicit: str | None, distro: str, port: int) -> list[str]:
|
||||
if explicit:
|
||||
return [explicit.rstrip("/")]
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user