Files
oc_session/oc.py
T

542 lines
22 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 -*-
"""
oc.py —— OC 工具箱统一入口(数字 / 中文菜单)
启动即打印菜单,输入编号或中文都能进:
0 / 返回 / 退出 / q 退出
1 / 技能 / 技能管理 / skill 进入技能管理(列表 / 推送 / 拉取 / 目录)
2 / 连接器 / 连接器管理 / mcp 进入连接器管理(状态 / 开关 / 模式 / 备份)
也支持免菜单直传子命令(等价旧的分散入口):
python oc.py skill list
python oc.py mcp status
python oc.py list (自动识别归属到技能管理)
底层实现复用同目录 ocskill.py / mcpctl.py,本文件只负责交互编排。
"""
import os
import sys
# 保证同目录模块可导入(无论从哪个工作目录启动)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mcpctl # noqa: E402
import ocskill # noqa: E402
# ---------------------------------------------------------------- 控制台
def fix_console() -> None:
"""Windows 控制台中文输出兜底(UTF-8 重配)。"""
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
reconf = getattr(stream, "reconfigure", None)
if callable(reconf):
try:
reconf(encoding="utf-8", errors="replace")
except Exception:
pass
# ---------------------------------------------------------------- 输入解析
def _norm(text: str) -> str:
return (text or "").strip().lower().replace(" ", "")
# 顶层别名:值 -> 传给对应函数的“行为键”
TOP_ALIASES = {
"0": "quit", "0.": "quit", "q": "quit", "quit": "quit", "exit": "quit",
"退出": "quit", "返回": "quit", "回": "quit",
"1": "skill", "1.": "skill", "skill": "skill", "skills": "skill",
"技能": "skill", "技能管理": "skill", "管理技能": "skill", "技": "skill",
"2": "mcp", "2.": "mcp", "mcp": "mcp", "连接器": "mcp",
"连接器管理": "mcp", "管理连接器": "mcp", "连接": "mcp", "连": "mcp",
"connector": "mcp", "connectors": "mcp",
}
SKILL_ALIASES = {
"0": "back", "0.": "back", "b": "back", "back": "back", "返回": "back", "回": "back",
"1": "list", "1.": "list", "list": "list", "列表": "list", "查看": "list",
"技能列表": "list", "看列表": "list",
"2": "push", "2.": "push", "push": "push", "推送": "push", "上传": "push", "推": "push",
"覆盖更新": "push", "更新": "push", "覆盖": "push",
"3": "pull", "3.": "pull", "pull": "pull", "拉取": "pull", "下载": "pull", "拉": "pull",
"4": "dirs", "4.": "dirs", "dirs": "dirs", "目录": "dirs", "状态": "dirs",
"技能目录": "dirs",
"5": "delete", "5.": "delete", "delete": "delete", "del": "delete",
"删除": "delete", "删": "delete", "移除": "delete", "删掉": "delete",
}
MCP_ALIASES = {
"0": "back", "0.": "back", "b": "back", "back": "back", "返回": "back", "回": "back",
"1": "status", "1.": "status", "status": "status", "状态": "status", "查看": "status",
"连接器状态": "status",
"2": "on", "2.": "on", "on": "on", "开": "on", "开启": "on", "打开": "on",
"3": "off", "3.": "off", "off": "off", "关": "off", "关闭": "off",
"4": "mode", "4.": "mode", "mode": "mode", "模式": "mode",
"5": "backup", "5.": "backup", "backup": "backup", "备份": "backup",
}
# /home 别名集:任意子菜单输入即直接跳回首页(顶层菜单),不再一层层按 0 返回
HOME_KEYS = {"/home", "home", "首页", "返回首页", "回首页", "回主页", "主菜单", "主页"}
class GoHome(Exception):
"""在任意位置输入 /home(或「首页」等)—— 一路抛到顶层菜单重绘首页。"""
def ask(prompt: str) -> str:
try:
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:
key = _norm(raw)
if key in aliases:
return aliases[key]
# 中文模糊包含:输入“技能管理菜单”也能命中“技能管理”
for k, v in aliases.items():
if k and k in key and not k.isdigit():
return v
return default
def pause(msg: str = "按回车返回…") -> None:
try:
raw = input(msg)
except (EOFError, KeyboardInterrupt):
print()
return
if _norm(raw) in HOME_KEYS:
raise GoHome()
def banner(title: str) -> None:
print()
print("=" * 44)
print(" " + title)
print("=" * 44)
def run_safe(fn, argv: list[str]) -> int:
"""菜单内调用 ocskill/mcpctl 的统一护栏:
- KeyboardInterrupt -> 打印“已取消”,回到菜单(不杀进程)
- SystemExit(push/pull 里的 raise SystemExit)-> 报告信息,
绝不让它穿透把整个菜单进程结束掉
- 其它异常 -> 打印错误,回到菜单
返回子命令退出码(仅直传模式会用到)。"""
try:
return fn(argv)
except KeyboardInterrupt:
print("\n已取消当前操作,返回菜单。")
return 130
except SystemExit as exc:
code = exc.code
if code is None:
return 0
if isinstance(code, int):
if code != 0:
print(f"操作未完成(退出码 {code}),返回菜单。")
return code
print(f"操作未完成:{code},返回菜单。")
return 1
except Exception as exc:
print(f"操作出错:{exc},返回菜单。")
return 1
# ---------------------------------------------------------------- 子菜单:技能管理
def _yes(prompt: str) -> bool:
"""统一的 y/N 判定(默认 N,回车即取消)。"""
return match(ask(prompt), {"y": "y", "yes": "y", "是": "y"}, None) == "y"
def _pick_agent(prompt: str, word: str) -> dict | None:
"""选来源/目标终端(宝宝级):先列可用终端,再收输入。
回车 = wsl(内置默认);简称前缀(wb…);列表序号(1./2.);或直接贴一整个 skill 目录。
输入 0/q/取消 返回 None。返回 ocskill.resolve_agent 的解析结果(kind/label/dir)。
"""
table = ocskill.load_agent_table()
print(" 可选终端(agents.md 每行一个,新增一行 = 新增一个终端):")
print(" · wsl(内置默认,回车即选)")
for i, t in enumerate(table, 1):
print(" %d. %s(%s) %s" % (i, t["key"], t["display"], t["dir"]))
raw = ocskill.clean_input(ask(prompt))
if _norm(raw) in ("0", "q", "quit", "取消", "back", "返回", "b"):
print(" 已取消。")
return None
if raw.isdigit() and table and 1 <= int(raw) <= len(table):
raw = table[int(raw) - 1]["key"]
res = ocskill.resolve_agent(raw)
if not res.get("ok"):
print(" %s" % res.get("err"))
return None
print(" %s:%s -> %s" % (word, res["label"], res["dir"]))
return res
def _gate_unc(root: str, action: str) -> bool:
"""来源/目标是 WSL UNC 时先唤醒 VM(本机目录完全不碰 WSL)。
返回 False = 唤醒失败(错误已打印)。"""
if not ocskill._is_unc_root(root):
return True
err = ocskill._wsl_ensure_running(ocskill.DEFAULT_DISTRO, action)
if err:
print(" %s" % err)
return False
return True
def _confirm_sel(idx: int, names: list[str], root: str, extra: str = "") -> bool:
"""编号选择的二次确认:打印 skill 名字 + 简介,confirm 执行 / cancel 回退。
(`N -confirm` 已在解析层跳过本步;这里只处理 `N` / `N.` 的常规确认。)
"""
name = names[idx - 1]
print(" 已选 %d:%s" % (idx, name))
brief = ocskill.skill_brief(name, root)
if brief.get("ok"):
desc = (brief.get("description") or "").replace("\n", " ").strip()
if desc:
print(" 简介:%s" % (desc[:100] + ("..." if len(desc) > 100 else "")))
print(" 路径:%s" % brief.get("path"))
else:
print(" %s" % brief.get("err"))
if extra:
print(" %s" % extra)
while True:
ans = _norm(ask(" 输入 confirm 执行 / cancel 回退: "))
if ans in ("confirm", "确认", "确定", "yes", "y"):
return True
if ans in ("cancel", "取消", "0", "返回", "回", "b", "back", ""):
print(" 已回退。")
return False
print(" 没看懂「%s」:confirm 执行,cancel 回退。" % ans)
def _pull_one(name: str, src: dict) -> None:
"""按已解析来源拉取一个 skill:探查 -> 无同名直接拉;确有同名才问一次覆盖。"""
probe = ocskill.probe_pull(name, root=src["dir"])
if not probe.get("ok"):
print(" %s" % probe.get("err"))
return
print(" 输出:%s(固定,不需选择)" % probe["dest"])
argv = ["pull", name]
if src["kind"] != "default":
argv += ["--from", src["label"]]
if not probe.get("exists"):
print(" 本机无同名目录,直接拉取。")
run_safe(ocskill.main, argv)
return
if _yes(" 本机已存在同名 skill「%s」,覆盖?(y/N): " % name):
print(" 覆盖更新(旧版本先挑进 .trash/ 备份)。")
run_safe(ocskill.main, argv + ["--force"])
else:
print(" 已跳过覆盖,未改动任何文件。")
def _flow_push() -> None:
"""推送 / 覆盖更新(宝宝级):选目标终端 -> 打印本机 skill 编号列表 ->
输编号选技能(1 / 1. 二次确认打印名字+简介,confirm 执行 / cancel 回退;
1 -confirm 跳过二次确认)或直接贴路径 / zip / SKILL.md -> 校验规范 -> 探查同名
-> 确有同名才问一次覆盖 -> 写入。
目标没有同名文件夹时直接写(没有目录和内容,覆盖无从谈起,问了也是白问)。
"""
tgt = _pick_agent(" 目标终端(回车=wsl,简称如 wb / oc,或贴完整目录,0/q 取消): ", "目标")
if tgt is None:
return
if not _gate_unc(tgt["dir"], "push"):
return
# 选完目标就打本机 skill 列表(带编号)——push 要推的是本机技能
local_names = ocskill.list_skill_names(ocskill.WIN_LOCAL_ROOT)
if local_names:
ocskill.render_page(local_names, 1, ocskill.WIN_LOCAL_ROOT)
print(" 提示:编号 1 / 1. 会打印名字+简介二次确认(confirm 执行 / cancel 回退),"
"1 -confirm 跳过确认;也可以直接贴路径 / zip / SKILL.md")
src = ocskill.clean_input(ask(" 技能输入(编号 / 路径 / zip / SKILL.md,可带引号,回车取消): "))
if not src:
return
# 0) 编号选择本机 skill(协议:1 / 1. 二次确认,1 -confirm 跳过)
sel = ocskill.parse_sel(src)
if sel is not None:
if not local_names:
print(" 本机 skill 目录为空,编号没得选;请直接贴路径 / zip。")
return
idx, skip = sel
if not (1 <= idx <= len(local_names)):
print(" 序号超出范围(1-%d)。" % len(local_names))
return
if not (skip or _confirm_sel(idx, local_names, ocskill.WIN_LOCAL_ROOT,
extra="目标:%s" % tgt["dir"])):
print(" 已取消推送。")
return
src = os.path.join(ocskill.WIN_LOCAL_ROOT, local_names[idx - 1])
print(" 选定:「%s」 -> %s" % (local_names[idx - 1], src))
# 1) 先过一遍规范校验(只读探查,不写盘)
probe = ocskill.probe_push(src, root=tgt["dir"])
if not probe.get("ok"):
print(" 校验未通过:%s" % probe.get("err"))
return
name = probe["name"]
desc = (probe.get("description") or "").replace("\n", " ")
print(" 校验通过:「%s」" % name)
if desc:
print(" 描述:%s" % (desc[:90] + ("..." if len(desc) > 90 else "")))
print(" 输出:%s" % probe["dest"])
argv = ["push", src]
if tgt["kind"] != "default":
argv += ["--to", tgt["label"]]
# 2) 探查同名:不存在 -> 直接放,不问覆盖
if not probe.get("exists"):
print(" 目标无同名目录,直接推送。")
run_safe(ocskill.main, argv)
return
# 3) 确有同名文件夹 -> 这才有资格问一次覆盖
if _yes(" 已存在同名 skill「%s」,覆盖?(y/N): " % name):
print(" 覆盖更新(旧版本先挑进 .trash/ 备份)。")
run_safe(ocskill.main, argv + ["--force"])
else:
print(" 已跳过覆盖,未改动任何文件。")
def _flow_pull() -> None:
"""拉取(宝宝级):选来源 -> 立即打印带编号的 skill 列表(每页 15 个,全局序号
跨页连续)—— 记得名字直接输名字;不记得输编号:1 / 1. 进二次确认(打印 skill
名字 + 简介,confirm 执行 / cancel 回退列表),1 -confirm 跳过二次确认;
翻页:下一页/上一页/第N页/next/last;q 退出。
输出固定为本机 OC skill 目录;列表直读文件夹,不启动 OC 服务、不发请求;
覆盖只在确有同名目录时问一次。
"""
src = _pick_agent(" 来源终端(回车=wsl,简称如 wb / oc,或贴完整目录,0/q 取消): ", "来源")
if src is None:
return
if not _gate_unc(src["dir"], "pull"):
return
root = src["dir"]
names = ocskill.list_skill_names(root)
if not names:
# 目录为空:没得列,退化成纯名字输入(名字直拉仍然有效)
print(" 这个来源下还没有 skill:%s" % root)
name = ocskill.clean_input(ask(" 技能名(直接输名字试试,回车取消): "))
if name:
_pull_one(name, src)
return
print(" 提示:名字直接拉;编号 1 / 1. 会打印名字+简介二次确认(confirm 执行 / "
"cancel 回退),1 -confirm 跳过确认")
page = 1
while True:
# 选完来源第一次进循环就把列表(带编号)打出来
page, pages = ocskill.render_page(names, page, root)
cmd = ocskill.clean_input(ask(
" 拉取:输名字或序号;翻页:下一页/上一页/第N页/next/last;q 退出: "))
if not cmd:
print(" (空输入。名字/序号 = 拉取,下一页/上一页 = 翻页,q = 退出)")
continue
if _norm(cmd) in ("q", "quit", "退出", "取消", "0", "b", "back", "返回"):
print(" 已退出列表,未拉取。")
return
# 1) 翻页命令(中英都认:下一页/第N页/上一页、next/last/N page)
np = ocskill.parse_page_cmd(cmd, page, pages)
if np is not None:
page = np
continue
# 2) 编号选择(协议:1 / 1. 二次确认;1 -confirm 跳过确认)
sel = ocskill.parse_sel(cmd)
if sel is not None:
idx, skip = sel
if not (1 <= idx <= len(names)):
print(" 序号超出范围(1-%d),重输。" % len(names))
continue
if skip or _confirm_sel(idx, names, root):
_pull_one(names[idx - 1], src)
return
# cancel -> 回退,留在列表重选
continue
# 3) 直接输名字(记得名字不需确认,直接拉)
if ocskill.NAME_RE.match(cmd):
if ocskill.probe_pull(cmd, root=root).get("ok"):
_pull_one(cmd, src)
return
print(" 来源里没有「%s」,留在列表重输(序号或名字)。" % cmd)
continue
print(" 没看懂「%s」:序号/名字拉取,下一页/上一页翻页,q 退出。" % cmd)
def _flow_delete() -> None:
"""删除:输入技能名 -> 选端 -> 展示将删路径 -> 确认 -> 删除(先挑 .trash/ 可恢复)。"""
name = ocskill.clean_input(ask(" 要删除的技能名(可带引号,回车取消): "))
if not name:
return
side = match(
ask(" 删哪端?(1=Wsl / 2=本机,回车取消): "),
{"1": "wsl", "1.": "wsl", "wsl": "wsl", "ws": "wsl", "远程": "wsl", "wsl端": "wsl",
"2": "local", "2.": "local", "local": "local", "本机": "local", "本地": "local", "本机端": "local"},
None,
)
if side is None:
print(" 已取消。")
return
local = side == "local"
probe = ocskill.probe_delete(name, ocskill.DEFAULT_DISTRO, local)
if not probe.get("ok"):
print(" %s" % probe.get("err"))
return
print(" 将删除%s:%s" % (probe["side"], probe["dest"]))
print(" (会先挑进 .trash/ 备份,删错了可移回)")
if not _yes(" 确认删除?(y/N): "):
print(" 已取消删除。")
return
argv = ["delete", name, "--yes"]
if local:
argv.append("--local")
run_safe(ocskill.main, argv)
def menu_skill(shared: dict) -> None:
while True:
banner("技能管理(skill)")
print(" 0. 返回上层")
print(" 1. 查看技能列表")
print(" 2. 推送 / 覆盖更新技能到目标终端(选完列本机编号:1 / 1. 二次确认,"
"1 -confirm 跳过;也可贴路径/zip)")
print(" 3. 拉取技能到本机 OC(选来源即出编号列表:名字直拉;1 / 1. 二次确认,"
"1 -confirm 跳过确认)")
print(" 4. 查看技能目录与连接状态")
print(" 5. 删除技能(先挑 .trash/ 备份,可恢复)")
print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单")
print(" 覆盖只在确有同名文件夹时才会问你一次")
raw = ask("请选择(编号或中文,/home 回首页): ")
act = match(raw, SKILL_ALIASES)
if act is None:
print(" 没看懂「%s」,输入 0-5 或中文,如:列表 / 推送 / 拉取 / 删除" % raw.strip())
continue
if act == "back":
return
if act == "list":
run_safe(ocskill.main, ["list"])
elif act == "dirs":
run_safe(ocskill.main, ["dirs"])
elif act == "push":
_flow_push()
elif act == "pull":
_flow_pull()
elif act == "delete":
_flow_delete()
pause()
# ---------------------------------------------------------------- 子菜单:连接器管理
def menu_mcp(shared: dict) -> None:
# 目标(本机 / WSL)本次会话记住,子菜单里随时可换
target = shared.get("target", "local")
while True:
banner("连接器管理(MCP,wb 语境的“连接器”)")
print(" 当前目标: %s" % ("本机" if target == "local" else "WSL"))
print(" 0. 返回上层")
print(" 1. 查看状态(模式 + 开关)")
print(" 2. 开启连接器(对调为含 MCP 配置 + 热重载)")
print(" 3. 关闭连接器(对调为安静配置 + 热重载)")
print(" 4. 查看 / 修改模式(自动 / 手动 / 关闭)")
print(" 5. 备份当前 mcpcfg.txt")
print(" t. 切换目标(本机 <-> WSL)")
print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单")
raw = ask("请选择(编号或中文,/home 回首页): ")
key = _norm(raw)
if key in ("t", "切换", "target", "目标"):
target = "wsl" if target == "local" else "local"
shared["target"] = target
print(" 已切换目标 -> %s" % ("本机" if target == "local" else "WSL"))
continue
act = match(raw, MCP_ALIASES)
if act is None:
print(" 没看懂「%s」,输入 0-5 / t 或中文,如:状态 / 开启 / 关闭 / 模式" % raw.strip())
continue
if act == "back":
return
base = ["--target", target]
if act == "status":
run_safe(mcpctl.main, base + ["status"])
elif act == "on":
run_safe(mcpctl.main, base + ["on"])
elif act == "off":
run_safe(mcpctl.main, base + ["off"])
elif act == "backup":
run_safe(mcpctl.main, base + ["backup"])
elif act == "mode":
val = ask(" 新模式(自动 / 手动 / 关闭,回车只查看): ").strip()
run_safe(mcpctl.main, base + ["mode"] + ([val] if val else []))
pause()
# ---------------------------------------------------------------- 顶层菜单
def menu_top() -> None:
shared: dict = {"target": "local"}
while True:
banner("OC 工具箱(输入编号或中文,0 退出)")
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())
continue
if act == "quit":
print("再见。")
return
if act == "skill":
try:
menu_skill(shared)
except GoHome:
print(" 已返回首页")
elif act == "mcp":
try:
menu_mcp(shared)
except GoHome:
print(" 已返回首页")
# ---------------------------------------------------------------- 直传模式
SKILL_CMDS = {"list", "push", "pull", "dirs", "delete"}
MCP_CMDS = {"status", "on", "off", "mode", "backup"}
def dispatch(argv: list[str]) -> int:
"""有参数时免菜单直传(兼容旧分散入口的用法)。"""
head = _norm(argv[0]) if argv else ""
rest = argv[1:]
if head in ("skill", "skills", "技能", "技能管理"):
return ocskill.main(rest)
if head in ("mcp", "连接器", "连接器管理"):
return mcpctl.main(rest)
if head in SKILL_CMDS:
return ocskill.main(argv)
if head in MCP_CMDS:
return mcpctl.main(argv)
print("未知命令: %s(技能: %s;连接器: %s)" % (
argv[0], "/".join(sorted(SKILL_CMDS)), "/".join(sorted(MCP_CMDS))))
return 2
def main() -> int:
fix_console()
if len(sys.argv) > 1:
return dispatch(sys.argv[1:])
menu_top()
return 0
if __name__ == "__main__":
sys.exit(main())