Files
oc_session/ocskill.py
T
ypshidifu 873d965ef3 wsl-round-mcp:本机与 WSL OpenCode 互通的 CLI 工具
ocskill.py(skill 互通,零第三方依赖):
- list 运行时连接 WSL 的 OC(/api/skill 与 /skill 双端点,Bearer→Basic 自动降级),连不上则降级扫描 skills 目录
- push 接收 zip / 目录 / 单个 SKILL.md / base64(显式或按内容嗅探),先解码归一,再校验标准 skill 格式
  校验四项:有 SKILL.md、有 YAML frontmatter、frontmatter 有 name 且合法、有 description
  含 zip-slip 目录穿越防护;同名已存在需 --force(覆盖前自动备份)
- pull 反向把 WSL 里的 skill 拷回本机;dirs 查看两端目录与连接状态
- 写入 \\wsl.localhost\<发行版>\root\.config\opencode\skills\,OC 原生 watch 自动热加载,无需重启

mcpctl.py(MCP 配置对调开关,与插件同一套逻辑):
- status / on / off / mode / backup;读同一份 mcpcfg.txt,状态只看活文件是否含 mcp.servers
- 对调后调用 POST /api/location/reload 热重载;模式门控与插件一致(关闭一律拒绝)

附 6 个双击 bat(英文文案规避 GBK 编码问题,内部 chcp 65001)与中文 README。
2026-09-23 12:06:32 +08:00

630 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ocskill —— 打通本机与 WSL OpenCode 的 skill 互通
做什么:
list 运行时连接 WSL 里的 OpenCode,返回它的 skill 列表
push 把一个新的 skill 文件(压缩包 / 目录 / 单个 SKILL.md / base64)
先解码并校验是不是标准 skill 格式,然后写进 OC 的 skill 目录
(压缩包会额外做一次解压;OC 自带目录 watch,写完即热加载)
pull 反向操作:把 WSL 里的某个 skill 拷回本机
dirs 显示两端 skill 目录位置
零依赖:只用 Python 标准库(urllib / zipfile / shutil),无需 pip install。
"""
import argparse
import base64
import binascii
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
# ---------------------------------------------------------------- 控制台编码
def _fix_console() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
_fix_console()
# ---------------------------------------------------------------- 常量与默认值
DEFAULT_DISTRO = os.environ.get("OC_SKILL_DISTRO", "Ubuntu-24.04")
DEFAULT_PORT = int(os.environ.get("OC_PORT", "4096"))
SKILL_MD = "SKILL.md"
# 标准 skill 目录:OC 会 watch 每个 config entry 下的 skill/ 与 skills/
WINDOWS_WSL_ROOT = r"\\wsl.localhost\{distro}\root\.config\opencode\skills"
LINUX_SKILL_ROOT = "/root/.config/opencode/skills"
WIN_LOCAL_ROOT = str(Path.home() / ".config" / "opencode" / "skills")
def is_wsl_inside() -> bool:
"""当前是否已经跑在 WSL/Linux 里。"""
if os.name != "posix":
return False
if sys.platform.startswith("linux"):
try:
with open("/proc/version", "r", encoding="utf-8", errors="ignore") as fh:
if "microsoft" in fh.read().lower():
return True
except Exception:
pass
return True # 纯 Linux 也走这个分支
return False
def wsl_skill_root(distro: str) -> str:
"""WSL 侧 skill 目录(在 Windows 下用 UNC 路径直接读写,无需进 WSL)。"""
if is_wsl_inside():
return LINUX_SKILL_ROOT
return WINDOWS_WSL_ROOT.format(distro=distro)
# ---------------------------------------------------------------- OC 连接
def _read_server_password(config_dir: Path) -> str:
"""从 opencode.json 读 server.password(WSL 端配置里有这一项)。"""
cfg = config_dir / "opencode.json"
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
pwd = (data.get("server") or {}).get("password")
return str(pwd) if pwd else ""
except Exception:
return ""
def _candidate_urls(explicit: str | None, distro: str, port: int) -> list[str]:
if explicit:
return [explicit.rstrip("/")]
urls = [
f"http://localhost:{port}",
f"http://127.0.0.1:{port}",
]
# Windows 下再试 WSL 的真实 IP(localhost 转发失效时兜底)
if os.name == "nt":
ip = _wsl_ip(distro)
if ip:
urls.append(f"http://{ip}:{port}")
seen, out = set(), []
for u in urls:
if u not in seen:
seen.add(u)
out.append(u)
return out
def _wsl_ip(distro: str) -> str:
try:
out = subprocess.run(
["wsl.exe", "-d", distro, "--", "bash", "-lc", "hostname -I"],
capture_output=True,
timeout=15,
)
text = (out.stdout or b"").decode("utf-8", errors="ignore")
for tok in text.split():
if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", tok):
return tok
except Exception:
pass
return ""
def _build_request(url: str, password: str, method: str = "GET") -> urllib.request.Request:
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}")
return req
def _try_fetch(url: str, password: str, timeout: float) -> tuple[int, bytes]:
"""返回 (status, body)。401 时自动改用 Basic 重试一次。"""
req = _build_request(url, password)
try:
with urllib.request.urlopen(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 urllib.request.urlopen(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""
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))
last_err = ""
for base in _candidate_urls(args.url, args.distro, args.port):
for path in ("/api/skill", "/skill"):
code, body = _try_fetch(base + path, password, args.timeout)
if code == 200:
try:
return base, json.loads(body.decode("utf-8", errors="replace"))
except Exception as exc:
last_err = f"{base}{path} 返回非 JSON:{exc}"
elif code in (401, 403):
last_err = f"{base}{path} 鉴权失败(HTTP {code}),请用 --password 指定 server.password"
elif code:
last_err = f"{base}{path} 返回 HTTP {code}"
else:
last_err = f"{base} 连接失败(OC 可能没在运行,试试 opencode serve --port {args.port})"
return None, None if not last_err else last_err
# ---------------------------------------------------------------- skill 解析
def extract_skills(payload) -> list[dict]:
"""容错解析 skill 列表:兼容数组 / {skills:[...]} / {data:{...}} 等形态。"""
items = None
if isinstance(payload, list):
items = payload
elif isinstance(payload, dict):
for key in ("skills", "items", "data", "result", "list"):
val = payload.get(key)
if isinstance(val, list):
items = val
break
if isinstance(val, dict): # data: { skills: [...] }
for inner in val.values():
if isinstance(inner, list):
items = inner
break
if items is not None:
break
if items is None:
items = next((v for v in payload.values() if isinstance(v, list)), [])
if items is None:
return []
out = []
for it in items:
if isinstance(it, str):
out.append({"name": it, "description": ""})
elif isinstance(it, dict):
name = it.get("name") or it.get("id") or it.get("title") or ""
desc = it.get("description") or it.get("summary") or ""
out.append({"name": str(name), "description": str(desc)})
return [x for x in out if x["name"]]
def scan_skill_dir(root: str) -> list[dict]:
"""API 不可用时的兜底:直接扫目录。"""
out = []
base = Path(root)
if not base.is_dir():
return out
for child in sorted(base.iterdir()):
if not child.is_dir() or child.name.startswith("."):
continue
md = child / SKILL_MD
desc = ""
if md.is_file():
desc = _frontmatter(md.read_text(encoding="utf-8", errors="replace")).get("description", "")
out.append({"name": child.name, "description": desc})
return out
BLOCK_MARKERS = {">", "|", ">-", "|-", ">+", "|+", ">", "|"}
def _has_cjk(text: str) -> bool:
"""是否含中日韩字符(决定块标量折行时要不要补空格)。"""
return any("\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f" for ch in text)
def _frontmatter(text: str) -> dict:
"""
解析 SKILL.md 顶部的 YAML frontmatter(只取扁平 key: value,不引第三方库)。
会正确处理块标量(description: > / |)——否则描述会显示成一个 ">"。
"""
if not text.startswith("---"):
return {}
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}
data: dict = {}
pending_key: str | None = None # 正在收集块标量的 key
block_lines: list[str] = []
block_indent: int | None = None
def flush() -> None:
nonlocal pending_key, block_lines, block_indent
if pending_key is not None:
parts = [x.strip() for x in block_lines if x.strip()]
# 中文行之间不补空格,英文单词之间才补
joiner = "" if _has_cjk("".join(parts)) else " "
data[pending_key] = joiner.join(parts)
pending_key, block_lines, block_indent = None, [], None
for line in lines[1:]:
if line.strip() in ("---", "..."):
flush()
break
if pending_key is not None:
# 块标量的续行:空行或更深缩进的都算
if line.strip() == "" or (line[:1] in (" ", "\t")):
block_lines.append(line)
continue
flush()
m = re.match(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$", line)
if not m:
continue
key, val = m.group(1), m.group(2).strip()
if val in BLOCK_MARKERS or val.rstrip() in BLOCK_MARKERS:
pending_key = key
block_lines = []
block_indent = None
continue
data[key] = val.strip("'\"")
flush()
return data
# ---------------------------------------------------------------- 标准 skill 校验
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def find_skill_md(folder: Path, depth: int = 0) -> Path | None:
"""在目录里找 SKILL.md(兼容压缩包自带一层父目录的情况)。"""
direct = folder / SKILL_MD
if direct.is_file():
return direct
if depth >= 3:
return None
for child in sorted(folder.iterdir()):
if child.is_dir() and not child.name.startswith("."):
found = find_skill_md(child, depth + 1)
if found:
return found
return None
def validate_skill(folder: Path) -> tuple[bool, str, dict]:
"""确认是不是标准 skill 格式:有 SKILL.md + YAML frontmatter + name。"""
md = find_skill_md(folder)
if not md:
return False, f"不是标准 skill 格式:找不到 {SKILL_MD}", {}
text = md.read_text(encoding="utf-8", errors="replace")
if not text.startswith("---"):
return False, f"{SKILL_MD} 缺少 YAML frontmatter(文件必须以 --- 开头)", {}
meta = _frontmatter(text)
if not meta:
return False, f"{SKILL_MD} 的 frontmatter 为空或无法解析", {}
name = meta.get("name", "")
if not name:
return False, f"{SKILL_MD} frontmatter 缺少必填的 name 字段", {}
if not NAME_RE.match(name):
return False, f"非法 skill 名称「{name}」(只允许字母/数字/点/下划线/连字符,且以字母或数字开头)", meta
if not meta.get("description"):
return False, f"{SKILL_MD} frontmatter 缺少 description 字段(大模型靠它判断何时调用)", meta
return True, "", meta
def decode_input(src: str, work: Path, b64: bool) -> Path | None:
"""
把用户传进来的输入统一解码成一个本地路径:
- base64 字符串 -> 解出 zip 或 markdown
- 其余情况原样返回(文件/目录交给后续步骤处理)
"""
if not b64:
return None
raw = src.strip()
try:
decoded = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as exc:
raise SystemExit(f"base64 解码失败:{exc}")
if decoded[:2] == b"PK": # zipfile magic
target = work / "decoded.zip"
target.write_bytes(decoded)
return target
target = work / "decoded.md"
target.write_bytes(decoded)
return target
def stage_candidate(src: str, work: Path, b64: bool) -> Path:
"""归一化输入 -> 返回一个包含 SKILL.md 的候选目录。"""
resolved = decode_input(src, work, b64)
path = Path(resolved) if resolved else Path(os.path.expandvars(os.path.expanduser(src)))
if not path.exists():
# 允许直接传一整段 base64(自动嗅探)
stripped = src.strip()
if _looks_like_b64(stripped):
sniff = work / "sniffed.bin"
try:
sniff.write_bytes(base64.b64decode(stripped, validate=True))
path = sniff
except Exception as exc:
raise SystemExit(f"输入既不是存在的路径,也不是合法 base64:{exc}")
else:
raise SystemExit(f"路径不存在:{path}")
if path.is_dir():
return path
# --- 到这里是单个文件:按魔数 / 后缀 / 内容三重嗅探 ---
if zipfile.is_zipfile(path):
return _unzip(path, work)
suffix = path.suffix.lower()
if suffix in (".7z", ".rar", ".tar", ".gz", ".tgz"):
raise SystemExit(f"暂不支持该压缩格式:{path.suffix}(请先转成 .zip)")
# 魔数兜底(无后缀或后缀写错的 zip)
try:
if path.read_bytes()[:2] == b"PK":
return _unzip(path, work)
except OSError:
pass
text = path.read_text(encoding="utf-8", errors="replace")
compact = "".join(text.split())
is_b64_text = (
len(compact) >= 64
and len(compact) % 4 == 0
and re.fullmatch(r"[A-Za-z0-9+/=]+", compact) is not None
)
# base64:显式后缀,或者内容本身就是一段 base64
if suffix in (".b64", ".base64", ".enc") or is_b64_text:
try:
decoded = base64.b64decode(compact, validate=True)
except Exception:
decoded = b""
if decoded[:2] == b"PK":
zipped = work / "sniffed.zip"
zipped.write_bytes(decoded)
return _unzip(zipped, work)
if decoded:
md_file = work / "decoded.md"
md_file.write_bytes(decoded)
text = decoded.decode("utf-8", "replace")
# markdown:frontmatter 开头,或者至少是个标题
stripped = text.lstrip()
if stripped.startswith("---") or stripped.startswith("#"):
meta = _frontmatter(text)
name = meta.get("name") or path.stem
safe = re.sub(r"[^\w.-]+", "-", name).strip("-") or path.stem
folder = work / safe
folder.mkdir(parents=True, exist_ok=True)
(folder / SKILL_MD).write_text(text, encoding="utf-8")
return folder
raise SystemExit(f"无法识别的输入:{path}(支持 .zip / 目录 / SKILL.md / base64 字符串)")
def _unzip(path: Path, work: Path) -> Path:
dest = work / ("unzipped_" + (path.stem or "archive"))
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path) as zf:
_safe_extract(zf, dest)
return dest
def _looks_like_b64(s: str) -> bool:
if len(s) < 64 or len(s) % 4 != 0:
return False
if not re.fullmatch(r"[A-Za-z0-9+/=\s]+", s):
return False
return True
def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
"""解压时防目录穿越(zip slip)。"""
for member in zf.infolist():
target = (dest / member.filename).resolve()
if not str(target).startswith(str(dest.resolve())):
raise SystemExit(f"压缩包内含非法路径,已拒绝解压:{member.filename}")
zf.extractall(dest)
# ---------------------------------------------------------------- 子命令
def cmd_list(args) -> int:
base, payload = connect_oc(args)
root = wsl_skill_root(args.distro)
if base:
skills = extract_skills(payload)
source = f"OpenCode API({base})"
else:
skills = scan_skill_dir(root)
reason = payload if isinstance(payload, str) else "未知原因"
source = f"目录扫描({root})—— API 不可用:{reason}"
print(f"skill 列表来源:{source}")
if not skills:
print("(没有找到任何 skill)")
return 1
width = max(len(s["name"]) for s in skills)
width = max(width, 4)
print(f"{'名称'.ljust(width)} 描述")
print("-" * (width + 40))
for s in skills:
desc = s["description"].replace("\n", " ")
if len(desc) > 70:
desc = desc[:67] + "..."
print(f"{s['name'].ljust(width)} {desc}")
print(f"\n共 {len(skills)} 个 skill。WSL skill 目录:{root}")
return 0
def cmd_push(args) -> int:
root = wsl_skill_root(args.distro)
root_path = Path(root)
if not root_path.is_dir():
if args.dry_run:
print(f"(dry-run)目标目录尚不存在:{root}")
else:
raise SystemExit(
f"找不到 WSL 的 skill 目录:{root}\n"
f"请确认 WSL 发行版名称(当前 {args.distro}),可用 --distro 指定。"
)
with tempfile.TemporaryDirectory(prefix="ocskill_") as tmp:
work = Path(tmp)
candidate = stage_candidate(args.source, work, args.base64)
ok, err, meta = validate_skill(candidate)
if not ok:
print(f"校验失败:{err}")
return 2
name = meta["name"]
desc = meta.get("description", "")
print(f"校验通过:标准 skill 格式")
print(f" 名称:{name}")
print(f" 描述:{desc[:100]}{'...' if len(desc) > 100 else ''}")
# SKILL.md 可能藏在压缩包的一层父目录里,定位到真正含 SKILL.md 的那层
md = find_skill_md(candidate)
real_dir = md.parent if md else candidate
dest = root_path / name
if args.dry_run:
print(f"dry-run:不会写入 {dest}")
return 0
if dest.exists():
if not args.force:
raise SystemExit(
f"目标已存在同名 skill:{dest}\n"
f"加 --force 覆盖(覆盖前会自动备份为 .bak-<时间戳>)。"
)
backup = dest.with_name(dest.name + f".bak-{_stamp()}")
shutil.move(str(dest), str(backup))
print(f"已备份旧版本 -> {backup}")
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(real_dir, dest, dirs_exist_ok=True)
print(f"已写入:{dest}")
print("OC 会 watch skill 目录(约 100ms 去抖后自动重载),无需重启。")
return 0
def cmd_pull(args) -> int:
root = wsl_skill_root(args.distro)
src = Path(root) / args.name
if not src.is_dir():
raise SystemExit(f"WSL 端不存在该 skill:{src}")
out_dir = Path(args.out or ".")
out_dir.mkdir(parents=True, exist_ok=True)
dest = out_dir / args.name
if dest.exists():
if not args.force:
raise SystemExit(f"本地已存在:{dest}(用 --force 覆盖)")
shutil.rmtree(dest)
shutil.copytree(src, dest)
print(f"已取回:{dest}")
return 0
def cmd_dirs(args) -> int:
print(f"WSL skill 目录(push 目标):{wsl_skill_root(args.distro)}")
print(f"本机 skill 目录(pull 默认落到当前目录):{WIN_LOCAL_ROOT}")
base, _ = connect_oc(args)
print(f"OpenCode API:{base or '未连接'}")
return 0
def _stamp() -> str:
import datetime
return datetime.datetime.now().strftime("%Y%m%d%H%M%S")
# ---------------------------------------------------------------- 入口
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="ocskill",
description="打通本机与 WSL OpenCode 的 skill 互通(list / push / pull / dirs)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python ocskill.py list\n"
" python ocskill.py push .\\my-skill.zip\n"
" python ocskill.py push .\\my-skill\\SKILL.md\n"
" python ocskill.py push <base64字符串> --base64\n"
" python ocskill.py push .\\my-skill --dry-run\n"
" python ocskill.py pull java-expert --out .\\skills-backup\n"
),
)
p.add_argument("--distro", default=DEFAULT_DISTRO, help=f"WSL 发行版名(默认 {DEFAULT_DISTRO})")
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(
"--remote-config-dir",
default="/root/.config/opencode",
help="读取 server.password 用的配置目录(Windows 侧用 WSL 内路径时才需要改)",
)
sub = p.add_subparsers(dest="cmd")
sp = sub.add_parser("list", help="连接 WSL 的 OC,返回 skill 列表")
sp.set_defaults(func=cmd_list)
sp = sub.add_parser("push", help="推送 skill(zip / 目录 / SKILL.md / base64)到 WSL OC")
sp.add_argument("source", help="skill 文件路径、目录、zip 或 base64 字符串")
sp.add_argument("--base64", action="store_true", help="把 source 当作 base64 内容解码")
sp.add_argument("--force", action="store_true", help="覆盖同名 skill(先自动备份)")
sp.add_argument("--dry-run", action="store_true", help="只校验格式,不写入")
sp.set_defaults(func=cmd_push)
sp = sub.add_parser("pull", help="把 WSL 里的 skill 拷回本机")
sp.add_argument("name", help="skill 名称")
sp.add_argument("--out", default=None, help="输出目录(默认当前目录)")
sp.add_argument("--force", action="store_true", help="覆盖本地同名目录")
sp.set_defaults(func=cmd_pull)
sp = sub.add_parser("dirs", help="显示两端 skill 目录与 API 连接状态")
sp.set_defaults(func=cmd_dirs)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not getattr(args, "cmd", None):
parser.print_help()
return 0
try:
return args.func(args)
except KeyboardInterrupt:
print("\n已取消。")
return 130
if __name__ == "__main__":
sys.exit(main())