Files
oc_session/oc.py
T

657 lines
29 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 编号列表
(列表跟随所选目录:选 wb 列 wb,2026-09-24 起不再硬编码本机 OC 目录;带翻页)->
翻页(下一页/上一页/第N页/next/last,与拉取端同一条 parse_page_cmd,越界拒绝跳转)、
输编号选技能(1 / 1. 二次确认打印名字+简介,confirm 执行 / cancel 回列表;
1 -confirm 跳过二次确认)或直接贴路径 / zip / SKILL.md -> 选【目标】终端 ->
校验规范 -> 探查同名 -> 确有同名才问一次覆盖 -> 写入。
目标没有同名文件夹时直接写(没有目录和内容,覆盖无从谈起,问了也是白问)。
来源与目标同目录时拦掉(自己推自己没有意义)。
"""
root_pick = _pick_agent(" 来源终端(列它的 skill,回车=wsl,简称如 wb / oc,或贴完整目录,0/q 取消): ", "来源")
if root_pick is None:
return
if not _gate_unc(root_pick["dir"], "push"):
return
# 列表 = 所选来源目录的 skill(选 wb 就列 wb —— 用户协议:选了目录就把 skill 打出来带编号)
root = root_pick["dir"]
local_names = ocskill.list_skill_names(root)
src = None
if not local_names:
# 所选目录没列表可打:退化成单次输入(没有编号/翻页可言)
src = ocskill.clean_input(ask(" 技能输入(路径 / zip / SKILL.md,可带引号,回车取消): "))
if not src:
return
else:
print(" 提示:编号 1 / 1. 会打印名字+简介二次确认(confirm 执行 / cancel 回列表),"
"1 -confirm 跳过确认;翻页:下一页/上一页/第N页/next/last/one page;"
"也可直接贴路径 / zip / SKILL.md")
page = 1
while True:
# 每次进循环先把当前页(带编号)打出来 —— 含首次进入(目录 = 所选来源)
page, pages = ocskill.render_page(local_names, page, root)
cmd = ocskill.clean_input(ask(
" 技能输入:翻页(下一页/上一页/第N页/next/last);编号选来源技能;"
"或贴路径 / zip / SKILL.md;q 取消: "))
if not cmd:
print(" 已取消推送(回车=取消;翻页 / 编号 / 路径都能输)。")
return
if _norm(cmd) in ("q", "quit", "退出", "取消", "0", "b", "back", "返回"):
print(" 已取消推送。")
return
# 1) 翻页命令(与拉取端同一条 parse_page_cmd:中英双语全支持;越界拒绝跳转)
np = ocskill.parse_page_cmd(cmd, page, pages)
if np is not None:
if not (1 <= np <= pages):
print(" 共 %d 页,没有第 %d 页(当前停在第 %d 页)。"
% (pages, np, page))
continue
page = np
continue
# 2) 编号选择来源 skill(协议:1 / 1. 二次确认,1 -confirm 跳过)
sel = ocskill.parse_sel(cmd)
if sel is not None:
idx, skip = sel
if not (1 <= idx <= len(local_names)):
print(" 序号超出范围(1-%d),重输。" % len(local_names))
continue
if not (skip or _confirm_sel(idx, local_names, root,
extra="来源:%s" % root)):
print(" 已回退到列表,未推送。")
continue
src = os.path.join(root, local_names[idx - 1])
print(" 选定:「%s」 -> %s" % (local_names[idx - 1], src))
break
# 3) 其余输入 = 路径 / zip / SKILL.md,交给下面的规范校验去判
src = cmd
break
if not src:
return
# 技能选定后才选目标终端(列表已按所选来源打出)
tgt = _pick_agent(" 目标终端(推送到哪,回车=wsl,简称如 wb / oc,或贴完整目录,0/q 取消): ", "目标")
if tgt is None:
print(" 已取消推送(技能已选好,未写入任何文件)。")
return
if not _gate_unc(tgt["dir"], "push"):
return
# 来源与目标同目录 = 自己推自己:拦掉(推了等于没推,-f 还可能自覆盖搞坏)
if os.path.isdir(src) and os.path.normcase(
os.path.abspath(os.path.dirname(os.path.abspath(src)))) == \
os.path.normcase(os.path.abspath(tgt["dir"])):
print(" 来源与目标是同一个目录(%s),自己推自己没有意义,已取消。"
% os.path.abspath(tgt["dir"]))
return
# 1) 先过一遍规范校验(只读探查,不写盘)
probe = ocskill.probe_push(src, root=tgt["dir"])
if not probe.get("ok"):
err = probe.get("err") or ""
if os.path.isdir(src) and ocskill.find_skill_md(ocskill.Path(src)) is None:
print(" 该目录缺 SKILL.md(空目录或不完整 skill),无法推送:%s" % src)
print(" (提示:回列表换一个,或直接贴完整 skill 的路径。)")
else:
print(" 校验未通过:%s" % 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:
if not (1 <= np <= pages):
print(" 共 %d 页,没有第 %d 页(当前停在第 %d 页)。"
% (pages, np, page))
continue
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 _delete_one(name: str, root: str) -> None:
"""按编号/名字删除一个 skill:先打印将删路径,再走 CLI 删除(自动挑 .trash/ 备份)。"""
probe = ocskill.probe_delete(name, ocskill.DEFAULT_DISTRO, root=root)
if not probe.get("ok"):
print(" %s" % probe.get("err"))
return
print(" 将删除%s:%s" % (probe["side"], probe["dest"]))
print(" (会先挑进 .trash/ 备份,删错了可移回)")
run_safe(ocskill.main, ["delete", name, "--yes", "--dir", root])
def _flow_delete() -> None:
"""删除(宝宝级,与推拉同一套编号协议):选【目标】终端 -> 立即打印该目录
skill 编号列表(每页 15 个、序号跨页连续,翻页绝对页码越界拒绝)->
输编号选技能:
`1 -confirm` 跳过二次确认直接删除(仍先挑 .trash/ 备份,可恢复);
`1` / `1.` 打印 skill 名字 + 描述 + 路径,confirm 确认删除 / cancel 取消不动;
也可直接输名字(删除是危险操作,同样走二次确认)。q 退出。
"""
tgt = _pick_agent(" 删哪端(回车=wsl,简称如 wb / oc,或贴完整目录,0/q 取消): ", "目标")
if tgt is None:
return
if not _gate_unc(tgt["dir"], "delete"):
return
root = tgt["dir"]
names = ocskill.list_skill_names(root)
if not names:
print(" 这个目录下还没有 skill:%s" % root)
return
print(" 提示:编号 1 / 1. 会打印名字+简介二次确认(confirm 删除 / cancel 回列表),"
"1 -confirm 直接删除;翻页:下一页/上一页/第N页/next/last;"
"也可直接输名字;删除先挑 .trash/ 备份,可恢复")
page = 1
while True:
# 每次进循环先把当前页(带编号)打出来 —— 含首次进入(目录 = 所选终端)
page, pages = ocskill.render_page(names, page, root)
cmd = ocskill.clean_input(ask(
" 删除:输编号(1 / 1. 确认,1 -confirm 直删)或名字;"
"翻页:下一页/上一页/第N页/next/last;q 取消: "))
if not cmd:
print(" (空输入。编号/名字 = 删除,下一页/上一页 = 翻页,q = 退出)")
continue
if _norm(cmd) in ("q", "quit", "退出", "取消", "0", "b", "back", "返回"):
print(" 已退出列表,未删除任何 skill。")
return
# 1) 翻页命令(与推拉同一条 parse_page_cmd:中英双语;绝对页码越界拒绝跳转)
np = ocskill.parse_page_cmd(cmd, page, pages)
if np is not None:
if not (1 <= np <= pages):
print(" 共 %d 页,没有第 %d 页(当前停在第 %d 页)。"
% (pages, np, page))
continue
page = np
continue
# 2) 编号选择(协议:1 -confirm 直删;1 / 1. 打印名字+简介+路径二次确认)
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
name = names[idx - 1]
if not (skip or _confirm_sel(idx, names, root,
extra="删除会先挑进 .trash/ 备份,可恢复")):
print(" 已取消,这个 skill 没动。")
continue
_delete_one(name, root)
return
# 3) 直接输名字(删除是危险操作,同样走二次确认,不给静默直删)
if ocskill.NAME_RE.match(cmd):
brief = ocskill.skill_brief(cmd, root)
if not brief.get("ok"):
print(" %s" % brief.get("err"))
continue
print(" 已选:%s" % cmd)
desc = (brief.get("description") or "").replace("\n", " ").strip()
if desc:
print(" 简介:%s" % (desc[:100] + ("..." if len(desc) > 100 else "")))
print(" 路径:%s" % brief.get("path"))
print(" 删除会先挑进 .trash/ 备份,可恢复")
ans = _norm(ask(" 输入 confirm 删除 / cancel 取消: "))
if ans not in ("confirm", "确认", "确定", "yes", "y"):
print(" 已取消,这个 skill 没动。")
continue
_delete_one(cmd, root)
return
print(" 没看懂「%s」:编号/名字删除,下一页/上一页翻页,q 退出。" % cmd)
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. 查看两端 skill 目录情况(路径 + skill 数量 + 本机/WSL API 分开探测)")
print(" 5. 删除技能(选端即出编号列表:1 -confirm 直删;1 / 1. 打印名字+简介"
"二次确认,confirm 删 / cancel 不动;先挑 .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())