推送走端口发现+GET探活+experimental路由;WSL读操作自动唤醒;CLI加/home任意位置回首页
This commit is contained in:
@@ -22,6 +22,9 @@
|
|||||||
|
|
||||||
- `1` / `技能管理` → 列表(1)、推送(2)、拉取(3)、目录(4);每层 `0` 返回
|
- `1` / `技能管理` → 列表(1)、推送(2)、拉取(3)、目录(4);每层 `0` 返回
|
||||||
- `2` / `连接器管理` → 状态(1)、开启(2)、关闭(3)、模式(4)、备份(5)、`t` 切换本机/WSL
|
- `2` / `连接器管理` → 状态(1)、开启(2)、关闭(3)、模式(4)、备份(5)、`t` 切换本机/WSL
|
||||||
|
- **任意位置输入 `/home`(或「首页 / 返回首页 / 回首页 / 回主页 / 主菜单 / 主页」)直接跳回顶层菜单**——
|
||||||
|
菜单位、参数位(路径/技能名/新模式 的输入框)、回车前都算,不用一层层按 `0`;
|
||||||
|
在顶层菜单输 `/home` 会提示「已经在首页了」
|
||||||
- 菜单内所有操作会逐项问参数(路径、是否覆盖…),回车即用默认值
|
- 菜单内所有操作会逐项问参数(路径、是否覆盖…),回车即用默认值
|
||||||
- 旧的 6 个分散 bat(skill-list / skill-push / …)已合并删除,功能全部并入本菜单
|
- 旧的 6 个分散 bat(skill-list / skill-push / …)已合并删除,功能全部并入本菜单
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ python ocskill.py dirs
|
|||||||
| `--distro` | `Ubuntu-24.04` | WSL 发行版名 |
|
| `--distro` | `Ubuntu-24.04` | WSL 发行版名 |
|
||||||
| `--url` | 自动探测 | 显式指定 OC 地址,如 `http://localhost:4096` |
|
| `--url` | 自动探测 | 显式指定 OC 地址,如 `http://localhost:4096` |
|
||||||
| `--port` | `4096` | OC 端口(等价于环境变量 `OC_PORT`) |
|
| `--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 配置对调开关
|
### 2. `mcpctl.py` — MCP 配置对调开关
|
||||||
|
|
||||||
@@ -136,10 +139,20 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载
|
|||||||
失败时配置文件已经对调完成,重启 OC 一样生效。
|
失败时配置文件已经对调完成,重启 OC 一样生效。
|
||||||
|
|
||||||
**开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前,
|
**开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前,
|
||||||
先把活配置里的 `mcp` 用 `PATCH /config` 推进**运行中的 OC**(三连尝试:SDK
|
先把活配置里的 `mcp` 推进**运行中的 OC**。三端同一套语义——**GET+merge 补 shell
|
||||||
`client.config.update({config})` → `({body})` → 原生 fetch)。推送成功再 reload
|
→ PATCH 真实路由**,按各自运行方式实现:
|
||||||
= 双保险,MCP 百分百加载;推送失败不阻断,自动降级为原 reload 单通道。
|
|
||||||
三个入口(TUI 插件 `/mcpon`、server 插件 `mcp_switch`、本 CLI `mcpctl on`)行为一致。
|
- **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 相关)
|
### 稳定性设计(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 里看到的样子) |
|
| 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`)提到最前,**子命令前后都能写** |
|
| 参数顺序被 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 永不返回 |
|
| `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` → 报告信息回菜单;异常 → 打印错误回菜单,菜单本体永不退出 |
|
| 菜单被子命令 `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`
|
1. 确认 WSL 里的 OC 起了:`opencode serve --port 4096`
|
||||||
2. 端口不对就加 `--port`,或设环境变量 `OC_PORT`
|
2. **端口不对不用管**:OC 每次启动端口随机(实测如 49374),工具会 `tasklist`
|
||||||
3. 提示 `401 WWW-Authenticate: Basic realm="Secure Area"` → OC 启用了服务密码。
|
找 `opencode.exe` 的 PID、`netstat -ano` 找它 LISTENING 的端口,
|
||||||
用 `--password` 传值(默认自动从 `opencode.json` 的 `server.password` 读;
|
再逐候选 `GET /api/config` 探活(200 = 命中);仍要显式指定就加 `--port`
|
||||||
若该字段为空但 OC 仍要 401,说明它用了随机密码,需启动时设 `OPENCODE_SERVER_PASSWORD`)
|
或环境变量 `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 再试
|
4. `localhost` 不通 → 工具会自动 `wsl hostname -I` 取 WSL 真实 IP 再试
|
||||||
5. 全都失败 → `list` 会自动降级为**直接扫目录**,照样能看到 skill 列表
|
5. 全都失败 → `list` 会自动降级为**直接扫目录**,照样能看到 skill 列表
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
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:
|
def wsl_gate(args, need_write: bool = False) -> str | None:
|
||||||
"""WSL 目标统一门(返回 None=可继续;返回字符串=错误信息):
|
"""WSL 目标统一门(返回 None=可继续;返回字符串=错误信息):
|
||||||
- 读操作(status/mode查看/backup):VM 没跑 → 秒回清晰提示,绝不撞 UNC 冷启动
|
- 读操作(status/mode查看/backup):VM 没跑 → 先自动唤醒再读,不再只提示
|
||||||
- 写操作(on/off/mode设置):VM 没跑 → 显式提示后唤醒,唤醒失败才报错"""
|
——「先给机器启动起来,再去读配置」;唤醒失败才报错
|
||||||
|
- 写操作(on/off/mode设置):同样唤醒(语义一致)
|
||||||
|
全程走 _wsl_safe_run(零管道),唤醒用 -- true 轻量拉起 VM。"""
|
||||||
if getattr(args, "target", "local") != "wsl" or os.name != "nt":
|
if getattr(args, "target", "local") != "wsl" or os.name != "nt":
|
||||||
return None
|
return None
|
||||||
if _wsl_running(args.distro):
|
if _wsl_running(args.distro):
|
||||||
return None
|
return None
|
||||||
if not need_write:
|
verb = "读配置" if not need_write else "写配置"
|
||||||
return (
|
print(f"WSL({args.distro})未运行,正在唤醒以便{verb}(可能需要十几秒)...", flush=True)
|
||||||
f"WSL({args.distro})未运行,读不到它的配置。"
|
|
||||||
f"先启动:wsl.exe -d {args.distro}"
|
|
||||||
)
|
|
||||||
print(f"WSL({args.distro})未运行,正在唤醒(可能需要十几秒)...", flush=True)
|
|
||||||
_wsl_safe_run(["wsl.exe", "-d", args.distro, "--", "true"], timeout=45)
|
_wsl_safe_run(["wsl.exe", "-d", args.distro, "--", "true"], timeout=45)
|
||||||
if _wsl_running(args.distro):
|
if _wsl_running(args.distro):
|
||||||
print("WSL 已就绪。", flush=True)
|
print("WSL 已就绪。", flush=True)
|
||||||
return None
|
return None
|
||||||
return (
|
return (
|
||||||
f"WSL({args.distro})唤醒失败,无法访问其配置。"
|
f"WSL({args.distro})唤醒失败,无法{verb}。"
|
||||||
f"请手动执行 wsl.exe -d {args.distro} 启动后重试。"
|
f"请手动执行 wsl.exe -d {args.distro} 启动后重试。"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,6 +319,20 @@ def swap_files(a: str, b: str) -> None:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- reload
|
# ---------------------------------------------------------------- reload
|
||||||
def _read_password(cfg_dir: Path) -> str:
|
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:
|
try:
|
||||||
data = json.loads((cfg_dir / "opencode.json").read_text(encoding="utf-8"))
|
data = json.loads((cfg_dir / "opencode.json").read_text(encoding="utf-8"))
|
||||||
pwd = (data.get("server") or {}).get("password")
|
pwd = (data.get("server") or {}).get("password")
|
||||||
@@ -357,62 +370,149 @@ def _post(
|
|||||||
url: str,
|
url: str,
|
||||||
password: str,
|
password: str,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
data: bytes = b"{}",
|
data: bytes | None = b"{}",
|
||||||
method: str = "POST",
|
method: str = "POST",
|
||||||
) -> tuple[int, str]:
|
) -> 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 = urllib.request.Request(url, method=method, data=data)
|
||||||
req.add_header("content-type", "application/json")
|
req.add_header("content-type", "application/json")
|
||||||
req.add_header("accept", "application/json")
|
req.add_header("accept", "application/json")
|
||||||
if password:
|
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:
|
try:
|
||||||
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
|
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
|
||||||
return resp.status, resp.read().decode("utf-8", "replace")
|
return resp.status, resp.read().decode("utf-8", "replace")
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
if exc.code == 401 and password:
|
return exc.code, (exc.read().decode("utf-8", "replace") if hasattr(exc, "read") else str(exc))
|
||||||
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)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return 0, str(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]:
|
def do_reload(target: str, args) -> tuple[bool, str]:
|
||||||
password = args.password or _read_password(
|
password = args.password or _read_password(
|
||||||
wsl_config_dir(args.distro) if target == "wsl" else _home_config()
|
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}"]
|
# 端口发现 + 探活:先找到真正在跑的 OC(4096 是默认值,实测常是随机端口)
|
||||||
if target == "wsl" and os.name == "nt":
|
host, _dir, why = _find_live_host(target, args, password)
|
||||||
ip = _wsl_ip(args.distro)
|
if not host:
|
||||||
if ip:
|
return False, why
|
||||||
hosts.append(f"http://{ip}:{args.port}")
|
code, body = _post(host + "/api/location/reload", password, args.timeout)
|
||||||
|
if 200 <= code < 300:
|
||||||
last = ""
|
return True, f"热重载成功({host}/api/location/reload)"
|
||||||
for host in hosts:
|
if code in (401, 403):
|
||||||
code, body = _post(host + "/api/location/reload", password, args.timeout)
|
return False, f"{host} 鉴权失败(HTTP {code}),请用 --password 指定口令"
|
||||||
if 200 <= code < 300:
|
if code == 404:
|
||||||
return True, f"热重载成功({host}/api/location/reload)"
|
return False, f"{host} 未提供 /api/location/reload(可能需要重启 OC 生效)"
|
||||||
if code in (401, 403):
|
if code:
|
||||||
last = f"{host} 鉴权失败(HTTP {code}),请用 --password 指定 server.password"
|
return False, f"{host} 返回 HTTP {code}:{body[:200]}"
|
||||||
elif code == 404:
|
return False, f"{host} 连不上(OC 可能没在运行)"
|
||||||
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 push_mcp_config(args) -> tuple[bool, str]:
|
def push_mcp_config(args) -> tuple[bool, str]:
|
||||||
"""reload 之前的兜底:把活配置里的 mcp 直接 PATCH 进运行中的 OC。
|
"""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 单通道。"""
|
只在「开启」方向推送;失败不阻断,继续走 reload 单通道。"""
|
||||||
path = resolve_cfg_file(args.target, args.distro)
|
path = resolve_cfg_file(args.target, args.distro)
|
||||||
cfg = read_cfg(path, 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(
|
password = args.password or _read_password(
|
||||||
wsl_config_dir(args.distro) if args.target == "wsl" else _home_config()
|
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}"]
|
# 1) 端口发现 + 探活:GET /api/config 200 = 命中运行中的 OC(4096 常无监听)
|
||||||
if args.target == "wsl" and os.name == "nt":
|
host, directory, why = _find_live_host(args.target, args, password)
|
||||||
ip = _wsl_ip(args.distro)
|
if not host:
|
||||||
if ip:
|
return False, why
|
||||||
hosts.append(f"http://{ip}:{args.port}")
|
encoded = urllib.parse.quote(directory, safe=":/")
|
||||||
|
|
||||||
body = json.dumps({"mcp": mcp}).encode("utf-8")
|
# 2) GET+merge 补 shell:GET 运行中 config 的 info.shell → 活文件 shell → null
|
||||||
last = ""
|
shell = None
|
||||||
for host in hosts:
|
gcode, gbody = _post(
|
||||||
code, resp = _post(
|
host + "/api/config?directory=" + encoded,
|
||||||
host + "/config", password, args.timeout, data=body, method="PATCH"
|
password, args.timeout, data=None, method="GET",
|
||||||
)
|
)
|
||||||
if 200 <= code < 300:
|
if gcode == 200:
|
||||||
return True, f"已推送({host}/config)"
|
try:
|
||||||
last = f"{host} HTTP {code}:{resp[:160]}"
|
got = json.loads(gbody.decode("utf-8", errors="replace"))
|
||||||
return False, last
|
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("--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("--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("--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("--no-reload", action="store_true", help="只对调文件,不调用热重载")
|
||||||
p.add_argument("--ignore-mode", 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:
|
def ask(prompt: str) -> str:
|
||||||
try:
|
try:
|
||||||
return input(prompt)
|
raw = input(prompt)
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
print()
|
print()
|
||||||
return "0"
|
return "0"
|
||||||
|
if _norm(raw) in HOME_KEYS:
|
||||||
|
raise GoHome()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def match(raw: str, aliases: dict, default: str | None = None) -> str | None:
|
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:
|
def pause(msg: str = "按回车返回…") -> None:
|
||||||
try:
|
try:
|
||||||
input(msg)
|
raw = input(msg)
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
print()
|
print()
|
||||||
|
return
|
||||||
|
if _norm(raw) in HOME_KEYS:
|
||||||
|
raise GoHome()
|
||||||
|
|
||||||
|
|
||||||
def banner(title: str) -> None:
|
def banner(title: str) -> None:
|
||||||
@@ -145,7 +159,8 @@ def menu_skill(shared: dict) -> None:
|
|||||||
print(" 2. 推送技能到 OC(zip / 目录 / SKILL.md / base64)")
|
print(" 2. 推送技能到 OC(zip / 目录 / SKILL.md / base64)")
|
||||||
print(" 3. 拉取技能到本机")
|
print(" 3. 拉取技能到本机")
|
||||||
print(" 4. 查看技能目录与连接状态")
|
print(" 4. 查看技能目录与连接状态")
|
||||||
raw = ask("请选择(编号或中文): ")
|
print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单")
|
||||||
|
raw = ask("请选择(编号或中文,/home 回首页): ")
|
||||||
act = match(raw, SKILL_ALIASES)
|
act = match(raw, SKILL_ALIASES)
|
||||||
if act is None:
|
if act is None:
|
||||||
print(" 没看懂「%s」,输入 0-4 或中文,如:列表 / 推送 / 拉取" % raw.strip())
|
print(" 没看懂「%s」,输入 0-4 或中文,如:列表 / 推送 / 拉取" % raw.strip())
|
||||||
@@ -195,7 +210,8 @@ def menu_mcp(shared: dict) -> None:
|
|||||||
print(" 4. 查看 / 修改模式(自动 / 手动 / 关闭)")
|
print(" 4. 查看 / 修改模式(自动 / 手动 / 关闭)")
|
||||||
print(" 5. 备份当前 mcpcfg.txt")
|
print(" 5. 备份当前 mcpcfg.txt")
|
||||||
print(" t. 切换目标(本机 <-> WSL)")
|
print(" t. 切换目标(本机 <-> WSL)")
|
||||||
raw = ask("请选择(编号或中文): ")
|
print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单")
|
||||||
|
raw = ask("请选择(编号或中文,/home 回首页): ")
|
||||||
key = _norm(raw)
|
key = _norm(raw)
|
||||||
if key in ("t", "切换", "target", "目标"):
|
if key in ("t", "切换", "target", "目标"):
|
||||||
target = "wsl" if target == "local" else "local"
|
target = "wsl" if target == "local" else "local"
|
||||||
@@ -232,7 +248,12 @@ def menu_top() -> None:
|
|||||||
print(" 0. 退出")
|
print(" 0. 退出")
|
||||||
print(" 1. 技能管理(skill 拉取 / 推送)")
|
print(" 1. 技能管理(skill 拉取 / 推送)")
|
||||||
print(" 2. 连接器管理(MCP 开关 / 模式)")
|
print(" 2. 连接器管理(MCP 开关 / 模式)")
|
||||||
raw = ask("请选择: ")
|
print(" 提示:子菜单任意位置输入 /home 或「返回首页」可直接回到本菜单")
|
||||||
|
try:
|
||||||
|
raw = ask("请选择: ")
|
||||||
|
except GoHome:
|
||||||
|
print(" 已经在首页了")
|
||||||
|
continue
|
||||||
act = match(raw, TOP_ALIASES)
|
act = match(raw, TOP_ALIASES)
|
||||||
if act is None:
|
if act is None:
|
||||||
print(" 没看懂「%s」,输入 0 / 1 / 2,或:退出 / 技能管理 / 连接器管理" % raw.strip())
|
print(" 没看懂「%s」,输入 0 / 1 / 2,或:退出 / 技能管理 / 连接器管理" % raw.strip())
|
||||||
@@ -241,9 +262,15 @@ def menu_top() -> None:
|
|||||||
print("再见。")
|
print("再见。")
|
||||||
return
|
return
|
||||||
if act == "skill":
|
if act == "skill":
|
||||||
menu_skill(shared)
|
try:
|
||||||
|
menu_skill(shared)
|
||||||
|
except GoHome:
|
||||||
|
print(" 已返回首页")
|
||||||
elif act == "mcp":
|
elif act == "mcp":
|
||||||
menu_mcp(shared)
|
try:
|
||||||
|
menu_mcp(shared)
|
||||||
|
except GoHome:
|
||||||
|
print(" 已返回首页")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 直传模式
|
# ---------------------------------------------------------------- 直传模式
|
||||||
|
|||||||
+99
-23
@@ -134,15 +134,83 @@ def _wsl_ensure_running(distro: str, purpose: str) -> str | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_server_password(config_dir: Path) -> str:
|
def _read_server_password(config_dir: Path, distro: str = "") -> str:
|
||||||
"""从 opencode.json 读 server.password(WSL 端配置里有这一项)。"""
|
"""口令读取链(与 mcpctl._read_password 一致):
|
||||||
cfg = config_dir / "opencode.json"
|
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:
|
try:
|
||||||
data = json.loads(cfg.read_text(encoding="utf-8"))
|
cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
pwd = (data.get("server") or {}).get("password")
|
tl = subprocess.run(
|
||||||
return str(pwd) if pwd else ""
|
["tasklist", "/fo", "csv", "/nh"],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||||
|
timeout=10, creationflags=cr,
|
||||||
|
).stdout.decode("utf-8", "ignore")
|
||||||
except Exception:
|
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]:
|
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://localhost:{port}",
|
||||||
f"http://127.0.0.1:{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 转发失效时兜底)
|
# Windows 下再试 WSL 的真实 IP(localhost 转发失效时兜底)
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
ip = _wsl_ip(distro)
|
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 = urllib.request.Request(url, method=method)
|
||||||
req.add_header("accept", "application/json")
|
req.add_header("accept", "application/json")
|
||||||
if password:
|
if password:
|
||||||
# OC 支持 Bearer 与 Basic 两种,先试 Bearer(401 时调用方会换 Basic 重试)
|
# OC server 固定用 Basic,用户名必须是 opencode(x/1/admin/root 全 401)
|
||||||
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}")
|
||||||
return req
|
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]:
|
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)
|
req = _build_request(url, password)
|
||||||
try:
|
try:
|
||||||
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
|
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
|
||||||
return resp.status, resp.read()
|
return resp.status, resp.read()
|
||||||
except urllib.error.HTTPError as exc:
|
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"")
|
return exc.code, (exc.read() if hasattr(exc, "read") else b"")
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0, b""
|
return 0, b""
|
||||||
@@ -224,7 +288,7 @@ def connect_oc(args) -> tuple[str | None, object]:
|
|||||||
连接 OC,返回 (base_url, payload)。
|
连接 OC,返回 (base_url, payload)。
|
||||||
依次尝试 /api/skill 与 /skill 两个已知端点;全失败则 payload=None。
|
依次尝试 /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 = ""
|
last_err = ""
|
||||||
for base in _candidate_urls(args.url, args.distro, args.port):
|
for base in _candidate_urls(args.url, args.distro, args.port):
|
||||||
for path in ("/api/skill", "/skill"):
|
for path in ("/api/skill", "/skill"):
|
||||||
@@ -515,13 +579,21 @@ def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- 子命令
|
# ---------------------------------------------------------------- 子命令
|
||||||
def cmd_list(args) -> int:
|
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)
|
base, payload = connect_oc(args)
|
||||||
root = wsl_skill_root(args.distro)
|
root = wsl_skill_root(args.distro)
|
||||||
|
|
||||||
if base:
|
if base:
|
||||||
skills = extract_skills(payload)
|
skills = extract_skills(payload)
|
||||||
source = f"OpenCode API({base})"
|
source = f"OpenCode API({base})"
|
||||||
|
elif gate_err:
|
||||||
|
skills = []
|
||||||
|
source = f"(WSL 未运行且 API 未连上,跳过目录扫描){gate_err}"
|
||||||
else:
|
else:
|
||||||
skills = scan_skill_dir(root)
|
skills = scan_skill_dir(root)
|
||||||
reason = payload if isinstance(payload, str) else "未知原因"
|
reason = payload if isinstance(payload, str) else "未知原因"
|
||||||
@@ -627,6 +699,10 @@ def cmd_pull(args) -> int:
|
|||||||
def cmd_dirs(args) -> int:
|
def cmd_dirs(args) -> int:
|
||||||
print(f"WSL skill 目录(push 目标):{wsl_skill_root(args.distro)}")
|
print(f"WSL skill 目录(push 目标):{wsl_skill_root(args.distro)}")
|
||||||
print(f"本机 skill 目录(pull 默认落到当前目录):{WIN_LOCAL_ROOT}")
|
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)
|
print("正在探测 OpenCode API 连接(约几秒;WSL 未运行会自动跳过 IP 探测)...", flush=True)
|
||||||
base, _ = connect_oc(args)
|
base, _ = connect_oc(args)
|
||||||
print(f"OpenCode API:{base or '未连接'}")
|
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("--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("--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("--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(
|
p.add_argument(
|
||||||
"--remote-config-dir",
|
"--remote-config-dir",
|
||||||
default="/root/.config/opencode",
|
default="/root/.config/opencode",
|
||||||
help="读取 server.password 用的配置目录(Windows 侧用 WSL 内路径时才需要改)",
|
help="读取口令用的配置目录(Windows 侧会自动换 UNC 视图,一般不用改)",
|
||||||
)
|
)
|
||||||
|
|
||||||
sub = p.add_subparsers(dest="cmd")
|
sub = p.add_subparsers(dest="cmd")
|
||||||
|
|||||||
Reference in New Issue
Block a user