Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08944e6bc2 | ||
|
|
6cda8d91b6 | ||
|
|
5c04f4f8b4 | ||
|
|
d13d7c1714 |
-21
@@ -1,21 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# 本地回滚/备份产物
|
||||
*.bak-*
|
||||
skills-backup/
|
||||
|
||||
# skill 扫盘注册表(本机运行数据:名字 -> 位置,含绝对路径,不入库)
|
||||
skill.json
|
||||
skill.json.tmp
|
||||
|
||||
# 单测临时输出
|
||||
_*.txt
|
||||
|
||||
# 编辑器与系统
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,299 +1,106 @@
|
||||
# wsl-round-mcp — 本机 ⇄ WSL OpenCode 互通工具
|
||||
# compaction-cn — 让 OC 对话压缩摘要输出简体中文
|
||||
|
||||
打通**本机 OpenCode** 与 **WSL 里的 OpenCode**:skill 列表互查、skill 一键推送、MCP 配置对调开关。
|
||||
全部零第三方依赖,只要装了 Python 3 就能跑。
|
||||
OpenCode 原生压缩(compaction)提示词硬编码在 `opencode.exe` 二进制里,摘要默认**纯英文**;
|
||||
后续任务里模型很容易被前面那段英文摘要带偏。本插件在压缩请求发出前往 `system` 追加
|
||||
中文输出指令,让摘要**内容用简体中文、标题原样保留英文**(标题不能译,原因见下)。
|
||||
|
||||
分支名:`compaction-cn` | 文件:`compaction-cn.ts`
|
||||
|
||||
---
|
||||
|
||||
## 一、双击就能用(小白版)
|
||||
## 一、安装
|
||||
|
||||
把整个 `wsl-round-mcp` 文件夹拷到任意位置,双击 **`oc.bat`** —— 只有这一个入口,
|
||||
启动打印数字菜单,**输编号或中文都能进**:
|
||||
源码真源 / 本机生效 / WSL 生效三份同步(MD5 必须一致):
|
||||
|
||||
```
|
||||
============================================
|
||||
OC 工具箱(输入编号或中文,0 退出)
|
||||
============================================
|
||||
0. 退出
|
||||
1. 技能管理(skill 拉取 / 推送)
|
||||
2. 连接器管理(MCP 开关 / 模式)
|
||||
请选择:
|
||||
源码真源 : D:\桌面\源码\oc_session\compaction-cn\compaction-cn.ts
|
||||
本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\compaction-cn.ts
|
||||
WSL 生效 : /root/.config/opencode/plugins/compaction-cn.ts
|
||||
```
|
||||
|
||||
- `1` / `技能管理` → 列表(1)、推送(2)、拉取(3)、目录(4);每层 `0` 返回
|
||||
- `2` / `连接器管理` → 状态(1)、开启(2)、关闭(3)、模式(4)、备份(5)、`t` 切换本机/WSL
|
||||
- **任意位置输入 `/home`(或「首页 / 返回首页 / 回首页 / 回主页 / 主菜单 / 主页」)直接跳回顶层菜单**——
|
||||
菜单位、参数位(路径/技能名/新模式 的输入框)、回车前都算,不用一层层按 `0`;
|
||||
在顶层菜单输 `/home` 会提示「已经在首页了」
|
||||
- 菜单内所有操作会逐项问参数(路径、是否覆盖…),回车即用默认值
|
||||
- 旧的 6 个分散 bat(skill-list / skill-push / …)已合并删除,功能全部并入本菜单
|
||||
|
||||
等价命令行(免菜单直传,写脚本时用):
|
||||
|
||||
```bash
|
||||
python oc.py skill list # = 旧 skill-list.bat
|
||||
python oc.py mcp status # = 旧 mcp-status.bat
|
||||
python oc.py list # 自动识别归属
|
||||
```
|
||||
|
||||
> bat 内部会自动 `chcp 65001`,中文输出不会乱码。
|
||||
> 没装 Python 或没加进 PATH,会提示 `Python not found in PATH`。
|
||||
插件在 **session 初始化时**加载(不是进程启动时),装好后**新建一个会话**即可生效,
|
||||
已开会话不会热加载。
|
||||
|
||||
---
|
||||
|
||||
## 二、命令行用法
|
||||
## 二、为什么只能这样干预(2026-09-25 实测定稿)
|
||||
|
||||
### 1. `ocskill.py` — skill 互通
|
||||
|
||||
```bash
|
||||
# 列出 WSL OC 的 skill(直读 skill 目录,不走 API)
|
||||
python ocskill.py list
|
||||
|
||||
# 推送:压缩包(自动解压)
|
||||
python ocskill.py push .\my-skill.zip
|
||||
|
||||
# 推送:整个 skill 目录
|
||||
python ocskill.py push .\my-skill
|
||||
|
||||
# 推送:单个 SKILL.md(会自动包成以 name 命名的目录)
|
||||
python ocskill.py push .\SKILL.md
|
||||
|
||||
# 推送:base64 内容
|
||||
python ocskill.py push <一长串base64> --base64
|
||||
|
||||
# 只校验格式、不写入
|
||||
python ocskill.py push .\my-skill --dry-run
|
||||
|
||||
# 覆盖同名 skill(先自动备份成 .bak-时间戳)
|
||||
python ocskill.py push .\my-skill --force
|
||||
|
||||
# 反向:把 WSL 里的 skill 拷回本机
|
||||
python ocskill.py pull java-expert --out .\skills-backup
|
||||
|
||||
# 看两端目录情况(路径 + 存在性 + skill 数量 + 名字摘要;不探测 API)
|
||||
python ocskill.py dirs
|
||||
```
|
||||
|
||||
**push 的完整流程**(对应「先解码 → 确认标准 skill 格式 → 传入 skill 目录」):
|
||||
|
||||
1. **解码归一**:zip 就解压(带 zip-slip 目录穿越防护),目录原样用,
|
||||
单个 `.md` 会包成目录,base64 字符串先解成文件再判断是 zip 还是 md。
|
||||
2. **定位 `SKILL.md`**:允许压缩包里多套一层父目录(最深 3 层)。
|
||||
3. **校验标准 skill 格式**,四项全过才算通过:
|
||||
- 找得到 `SKILL.md`
|
||||
- 文件以 `---` 开头,有 YAML frontmatter
|
||||
- frontmatter 里有 `name`,且只含 `字母/数字/._-`
|
||||
- frontmatter 里有 `description`(大模型靠它决定何时调用)
|
||||
4. **写入** `\\wsl.localhost\<发行版>\root\.config\opencode\skills\<name>\`,
|
||||
OC 会 watch 这个目录(约 100ms 去抖后自动 rescan 并推 `skill.reload`),**不用重启**。
|
||||
|
||||
常用参数:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `--distro` | `Ubuntu-24.04` | WSL 发行版名 |
|
||||
|
||||
> 2026-09-24 起 `ocskill.py` 不再探测 OC API(`--url/--port/--timeout/--password`
|
||||
> 已移除):读 skill 一律直读目录,连上 WSL 就查目录,本机的就查本机 OC 目录。
|
||||
> `mcpctl.py`(配置对调 / 热重载)仍走 API,其 `--port/--password` 等参数不受影响。
|
||||
|
||||
### 2. `mcpctl.py` — MCP 配置对调开关
|
||||
|
||||
```bash
|
||||
python mcpctl.py status # 看状态(只读)
|
||||
python mcpctl.py on # 对调成含 MCP + 热重载
|
||||
python mcpctl.py off # 对调成安静版 + 热重载
|
||||
python mcpctl.py mode # 看当前模式
|
||||
python mcpctl.py mode 手动 # 改模式(自动/手动/关闭,或 auto/manual/off)
|
||||
python mcpctl.py status --target wsl # 操作 WSL 那一份配置
|
||||
python mcpctl.py off --no-reload # 只对调文件,不调用重载
|
||||
```
|
||||
|
||||
它读的正是 `mcpcfg.txt`(键值格式,小白直接改值即可):
|
||||
|
||||
```
|
||||
# MCP 开关配置:改完保存,重启 OC 生效
|
||||
模式: 自动 ← 自动 / 手动 / 关闭
|
||||
含MCP的配置: C:\...\opencode.json
|
||||
安静配置: C:\...\opencode.json.2
|
||||
```
|
||||
|
||||
兼容旧「裸三行」格式(第1行模式、第2行含MCP、第3行安静),
|
||||
插件/CLI 读到旧格式会**自动迁移写回**成上面的键值格式;
|
||||
两行路径指向的文件不存在时会**自动扫同目录 `opencode.json.*` 自愈**并写回,
|
||||
避免路径写错把 `/mcpon` `/mcpoff` 锁死。
|
||||
|
||||
**状态判定只看活文件**(文件名为 `opencode.json` 的那份)里 `mcp.servers` 是否非空,
|
||||
所以对调之后两行的身份怎么漂移都不影响判断——这与 TUI 插件、server 插件完全一致。
|
||||
另外 `on`/`off` 成功后会维护 `mcp.on.flag`(开写、关切):
|
||||
自动模式的「启动归位」见到标记就跳过,不会把刚开启的状态又换回去。
|
||||
|
||||
模式语义(三个入口共用同一套规则):
|
||||
|
||||
| 模式 | `/mcpon` `/mcpoff` | 大模型(server 插件) | 本工具 `mcpctl.py` |
|
||||
| --- | --- | --- | --- |
|
||||
| 自动 | 可用(视为手动切换一次) | 可用 | 可用 |
|
||||
| 手动 | **仅此可用** | 拒绝 | 可用(等价于用户输入命令) |
|
||||
| 关闭 | 不注册 | 拒绝 | 拒绝(可用 `--ignore-mode` 强行) |
|
||||
|
||||
热重载打的是 `POST /api/location/reload`,会重建 location 并重读磁盘配置,MCP 立即生效;
|
||||
失败时配置文件已经对调完成,重启 OC 一样生效。
|
||||
|
||||
**前置探活:没有运行中的 OC 就不重载、也不算失败(2026-09-25 用户反馈)**:
|
||||
对调完成后先 `_live_oc()` —— WSL 目标先查 VM 是否在跑(没跑直接跳过、不发网络请求),
|
||||
再端口发现 + 探活找运行中的 OC。都没有 → 打印「无需热重载:… 配置已对调完成,
|
||||
等 OC 下次启动时会自动加载新配置」并按成功返回,**不再报**
|
||||
「热重载失败:… WinError 10061 连接拒绝」(WSL/OC 都没启动时冷启动会自己加载)。
|
||||
探活命中的 host 直接复用给推送兜底与 reload(不重复探测);只有
|
||||
「OC 在跑但 reload 返回 401/404/错误」才照旧算真实失败。
|
||||
**措辞按状态说(2026-09-25 Round 9)**:连接被拒绝(WinError 10061)报
|
||||
「无进程监听(OC 未启动)」、超时报「连接超时(OC 未启动或不可达)」,
|
||||
skip 整句不再出现「连不上(urlopen error…)」被读成 API 报错;
|
||||
未知网络错误仍保留原文不吞。
|
||||
|
||||
**开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前,
|
||||
先把活配置里的 `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 相关)
|
||||
|
||||
| 问题 | 修复 |
|
||||
| 候选干预点 | 结论 |
|
||||
| --- | --- |
|
||||
| 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 / `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 永不返回 |
|
||||
| UNC 半死时 `mcp 查看` / `skill dirs` 无限挂起(`Path.exists()`/`read_text` 阻塞在系统调用层,Ctrl+C 都打不断) | 只读链路统一走 `_guard()`:fn 放进 daemon 工作线程,主线程 `queue.get(timeout)` 限时等(本机 8s / WSL UNC 20s)。超时打印「读取超时(>Ns)」并返回菜单,绝不再挂死;主线程是 Python 层等待,Ctrl+C 随时可打断。覆盖 status / mode 查看 / backup / on·off 初始读 / dirs 两端目录探查 |
|
||||
| 唤醒 WSL 的十几秒里屏幕无输出,看着像卡死 | 唤醒统一包 `_run_with_beats()`:每 5 秒打一个 `.`,配合「正在唤醒(可能需要十几秒,期间每 5 秒打一个点)」提示,等待过程可见 |
|
||||
| 菜单被子命令 `raise SystemExit` 杀死 | `oc.py` 的 `run_safe()` 护栏:`KeyboardInterrupt` → 已取消回菜单;`SystemExit` → 报告信息回菜单;异常 → 打印错误回菜单,菜单本体永不退出 |
|
||||
| V1 hooks(`default` 对象上的字段,含官方文档的 `experimental.session.compacting`) | ❌ 2.0.15 V2 运行时**零消费者**,探针 24 次 setup 全成功、`A.*` 日志全空;且 named export 返回 `hooks` 对象会被当 cleanup 调用 → `TypeError: Ae is not a function` 整批插件崩 |
|
||||
| `opencode.config.compaction` | ❌ schema 只有 `{auto, buffer, keep.tokens}`,没有 prompt 覆盖字段 |
|
||||
| `ctx.session.hook("model.request", cb)` | ❌ payload 是 `[sessionID, agent, model, kind, baseURL, headers]`,**没有 messages**,只能识别(`kind="compaction"`)不能改写内容 |
|
||||
| `ctx.session.hook("compaction", cb)` | ✅ **唯一可行**。payload = `[sessionID, model, system, messages, options, agent, tools]`,`system` 会带进最终压缩请求 |
|
||||
|
||||
`list` / `dirs` 连接 WSL 前会先打印「正在唤醒以便读配置…」(VM 没跑时,每 5 秒
|
||||
打一个点),几秒无响应时一眼看出是在唤醒而不是死了;`list` 唤醒失败**绝不**回落到
|
||||
UNC 目录扫描(那会触发冷启动卡死),直接报错返回。
|
||||
`dirs` 只展示两端目录信息(路径 + 存在性 + skill 数量 + 名字摘要,限时读取防卡死),
|
||||
**不再探测 OC API**(2026-09-24 用户反馈:读 skill 目录不需要 API,是画蛇添足)。
|
||||
压缩请求时序(二进制逆向):
|
||||
|
||||
**缺 SKILL.md 的软链接诊断(2026-09-24 新增)**:oc / wb 里的 skill 有些是软链接
|
||||
形式(不是实体拷贝),目录列得出来却找不到 SKILL.md。现在:
|
||||
- `list` 表格与菜单翻页里顶层缺 SKILL.md 的条目直接标 `⚠缺 SKILL.md`
|
||||
(与推送同名检查同一数据源 `find_skill_md`,选中前就能看出空壳 / 断链);
|
||||
- 编号选中(二次确认)时打印软链接诊断:目录/SKILL.md/子项是不是
|
||||
symlink 或 junction、目标指向哪、解出的真实位置有没有 SKILL.md;
|
||||
UNC 下 POSIX 目标换算回 `\\wsl.localhost\<distro>` 视图,本地解不出还会进 WSL
|
||||
用 `readlink` / `find -L` 再查一层;
|
||||
- WSL 内脚本走 **base64 传输**(`wsl.exe` 会把原始 commandline 交给 distro shell
|
||||
二次解析,script 双引号里的 `$var` 会被外层提前展开 —— 实测 `$1`/`$p` 全变空、
|
||||
`rc=0` 却零输出);
|
||||
- WSL 未运行时**不自动冷启动**,明确提示「跳过二层诊断,唤醒 WSL 后重试」;
|
||||
- 目标读不出来时不再用 realpath 硬解(那只会解出链接自身、误报断链),
|
||||
直接标注「疑似 WSL 内软链接,交由二层诊断」;
|
||||
- 推送失败(缺 SKILL.md)的报错里同样附带这份诊断,回答「真实文件在哪」。
|
||||
- **跨根兜底:空壳目录直接找出「真身在别处」(2026-09-25 用户反馈)**——
|
||||
实测来源目录(`.config/opencode/skills/<name>`,两端)是**真实空目录、不是链接**,
|
||||
但大模型照样能调用:真身在另一个根 `~/.workbuddy/skills/<name>/SKILL.md`
|
||||
(即 `wb` 终端)。结论行不再止步「仍未找到」,而是跨已知根找同名 SKILL.md:
|
||||
本机 `~/.workbuddy/skills`、`~/.config/opencode/skills`(含 `.trash` 回收站)、
|
||||
当前目录 `.workbuddy/skills`、WSL 端对应两根(folder 是 WSL UNC 直查,
|
||||
否则 VM 在跑才查),并可用环境变量 `OC_SKILL_ALT_ROOTS`(`os.pathsep` 分隔)
|
||||
追加自定义根;找到就列出真身路径(2026-09-25 Round 9 起菜单推送**自动切**到真身,
|
||||
不再要求手动换来源,见下条)。
|
||||
另外 WSL 二层查完**空结果也会说话**(「ℹ WSL 内二层已查:没有 SKILL.md、也没有链接」),
|
||||
不再静默——静默会被误读成二层没跑。
|
||||
- **推送自动切真身:源是软链接 / 空壳直接换路径,不提醒手动切(2026-09-25 Round 9)**——
|
||||
`ocskill.real_src()` 两级解析:链接(symlink / junction)先解链接目标(目标里有
|
||||
SKILL.md 才切),空壳再走上面的跨根真身列表;`oc.py` 选完源就地切换并打印
|
||||
「原 / 新」两行,随后按真身路径 probe + push。有效目录 / zip / SKILL.md 文件输入
|
||||
不动(返回 None)。实测:`.config/opencode/skills/memory-manager-v2__skillhub`
|
||||
是指向 `.cc-switch/skills/...` 的 junction,选它推送时自动推 cc-switch 真身。
|
||||
解不出真身时才落回原诊断(「自动切换也没找到真身,无法推送」)。
|
||||
|
||||
**skill.json 注册表:推拉遇空壳/链接的分层扫盘落库(2026-09-25 Round 10)**——
|
||||
不一上来就全盘忙扫,按三层走,且**推拉永远优先用户选定的目录**(选定目录有效就直接用,
|
||||
注册表只在空壳/链接时兜底解析真身):
|
||||
|
||||
| 层 | 时机 | 做什么 |
|
||||
| --- | --- | --- |
|
||||
| ① 内存注册表 | 启动即读 | `oc.py` / `ocskill.py` 启动时把 `skill.json` 读进内存(`load_registry()`),后续查询零扫盘 |
|
||||
| ② 内嵌常见位置快扫 | 空壳/链接、内存没命中 | `builtin_skill_roots()`:oc(`.config/opencode/skills`)、wb(`.workbuddy/skills`)、**cc-switch**、claude code + `agents.md` 终端表 + `OC_SKILL_ALT_ROOTS` + WSL 两根(VM 在跑才带,绝不为快扫冷启动),秒级、命中即落库 |
|
||||
| ③ 后台扫盘 | 上面都没中 | `start_background_scan()`:daemon 线程扫内嵌根 + 扩展浅层根(home / `.config` / `.local/share` / cwd 下一级的 `skills/`,不递归不全盘),**不阻塞当前推拉**;单飞(运行中不叠加线程);UNC 根 6s 限时防半死;扫完落库 |
|
||||
|
||||
落库结构就是「名字 -> 位置」的一堆键值(`skills.<name> = [路径…]`,只增不删,
|
||||
查询端做存在性 + `SKILL.md` 校验,过期条目只是查不到、不误导):
|
||||
|
||||
```json
|
||||
{ "version": 1, "updated": "2026-09-25 21:10:00",
|
||||
"skills": { "java-expert": ["C:\\Users\\…\\.workbuddy\\skills\\java-expert", "…"] } }
|
||||
```
|
||||
compaction hook(prep) → [...messages, 模板 Zt.user(z$(...))] → model.request(kind="compaction")
|
||||
```
|
||||
|
||||
- 文件位置:本目录 `skill.json`(**本机运行数据,已 gitignore,不入库**);
|
||||
- 推送(菜单 `_flow_push` + CLI `cmd_push`)、拉取(`cmd_pull`)**两端对称**接入:
|
||||
源是空壳/链接就走上面三层自动切真身并打印「原 / 新」,没找到真身则启动后台扫盘
|
||||
并提示「稍后重试即可自动切」;zip / SKILL.md / base64 输入不参与切换;
|
||||
- 扫描结果落库后,下次同名直接走 ① 内存命中(实测首次 0.49s、重启后 0.02s)。
|
||||
模板是在钩子**之后**才追加进 messages 的,所以钩子改不到模板本身,只能改 `system`。
|
||||
|
||||
**回归单测**:`python _test_diag.py`(空壳 / 正常 skill / junction 链接 / 断链 /
|
||||
大小写 / WSL UNC 真链接 / 跨根兜底 / real_src 自动切换等 20+ 项断言,2 个文件 symlink 场景在拦截 symlink 的
|
||||
机器上自动 SKIP;含 WSL 场景,首次跑会拉起 VM);
|
||||
`python _test_mcpctl.py`(热重载前置探活 + 探活措辞 10 项:未运行 skip 不探测、
|
||||
失败仍报错、host 复用、10061/10060 措辞、skip 整句不含「连不上」等);
|
||||
`python _test_registry.py`(skill.json 注册表 34 项:内存加载 / 落库原子写 / 损坏文件
|
||||
不炸 / 内嵌 oc·wb·cc-switch·claude 根 / 快扫命中即落库 / 分层查找顺序 / 后台扫盘单飞
|
||||
与完成落库 / 点前缀与无 SKILL.md 不登记 / 用户选定目录优先 / 扩展根浅层扫描)。
|
||||
### 关键约束:小节标题绝对不能翻译
|
||||
|
||||
输出校验 `ww = (e) => e.split("\n").some(a => U$.includes(a.trim()))`,
|
||||
`U$` 是模板按行过滤出的英文标题行。摘要里必须**整行命中**这些英文标题,否则**压缩直接失败**:
|
||||
|
||||
```
|
||||
## Objective / ## Requirements / ## Decisions / ## Work State /
|
||||
### Completed / ### Active / ### Blocked /
|
||||
## Next Move / ## Relevant Files / ## Important Context
|
||||
```
|
||||
|
||||
所以本插件只加规则「**内容中文、标题原样英文**」,外加路径/代码/URL/报错原文不翻译。
|
||||
|
||||
---
|
||||
|
||||
## 三、连不上 OC 怎么办
|
||||
## 三、工作方式
|
||||
|
||||
> 本节只影响 `mcpctl.py`(配置对调 / 热重载走 API)。
|
||||
> `ocskill.py` 的 list / pull / push / delete 已全部直读 skill 目录,**不连 OC API**,
|
||||
> OC 没在运行也能列 / 推 / 拉(写完 OC 起来后目录 watch 会自动热加载)。
|
||||
`setup(ctx)` 里向 `ctx.session.hook("compaction", cb)` 注册一个回调,往 `g.system`
|
||||
数组 push 一段带 `<compaction-language>` 标记的文本。带标记是为了**幂等判重**——
|
||||
多 location、多次 reload 可能重复注册钩子,已注入过就不再加。
|
||||
|
||||
**先看这一条(最常见):有 HTTP 代理时会假报 502。**
|
||||
日志(不进 stdout,避免污染 TUI):
|
||||
|
||||
`mcpctl` 默认**绕过系统代理**直连(`ProxyHandler({})`)。
|
||||
如果环境里有 `HTTP_PROXY` / `HTTPS_PROXY`,`urllib` 默认会把 `localhost` 请求也丢给代理,
|
||||
结果是**代理回 502**,看起来像 OC 挂了、其实是被劫走了。
|
||||
确实需要走代理时,设环境变量:`MCPCTL_USE_PROXY=1`。
|
||||
```
|
||||
C:\Users\ypshidifu\.config\opencode\compaction-cn.log # Windows
|
||||
/root/.config/opencode/compaction-cn.log # WSL
|
||||
```
|
||||
|
||||
其余排查:
|
||||
正常会看到:
|
||||
|
||||
1. 确认 WSL 里的 OC 起了:`opencode serve --port 4096`
|
||||
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. 全都失败 → skill 读写不受影响:`list` 本来就是**直接扫目录**,照样能看到 skill 列表
|
||||
```
|
||||
compaction 钩子注册完成 registration=[...]
|
||||
已注入中文输出指令 sessionID="ses_xxx" system段=5 messages=N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、和另外两个仓库的关系
|
||||
## 四、V2 插件契约(与 ai-mcp 同款,勿破坏)
|
||||
|
||||
本目录是独立 git 仓库,**分支名 = 文件夹名** `wsl-round-mcp`,远程与主仓库共用同一个根:
|
||||
- `default` 导出 **必须**给 `{ id, setup }`(缺了报 `Plugin must export a default definition...`)
|
||||
- **`setup` 必须返回 void**,绝不抛异常(返回 `hooks`/truthy 对象 → `Ae is not a function` 整批崩)
|
||||
- named export 用逗号表达式安全形态:`export const X: any = async (_ctx: any) => (await ({} as any), undefined as any)`
|
||||
- 本插件所有异常都在内部吞掉并落日志,绝不影响同批其它插件
|
||||
|
||||
| 文件夹 | 分支 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `oc_session/`(主仓库) | `main` | TUI 插件 `oc-session-all`(`/sessions-all`、`/mcpon`、`/mcpoff`) |
|
||||
| `oc_session/ai-mcp/` | `ai-mcp` | server 插件 `ai-mcp.ts`,给大模型用的 `mcp_switch` 工具 |
|
||||
| `oc_session/wsl-round-mcp/` | `wsl-round-mcp` | 本目录:skill 互通 CLI + MCP 命令行开关 |
|
||||
---
|
||||
|
||||
远程:`https://git.ypshidifu.cn/youpaishidifu/oc_session.git`
|
||||
## 五、效果对比
|
||||
|
||||
压缩前(原生英文输出):
|
||||
|
||||
```
|
||||
## Objective
|
||||
- Respond to the user with "收到".
|
||||
```
|
||||
|
||||
装插件后(e2e 实测,`has_en_headers=True`、英文标题原样保留、正文全中文):
|
||||
|
||||
```
|
||||
## Objective
|
||||
- 按用户要求回复"收到"。
|
||||
## Requirements
|
||||
- 回复内容必须为"收到"。
|
||||
...
|
||||
```
|
||||
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""diag_skill_md 单测:空壳 / 正常 skill / 链接 / 断链 / 大小写 / WSL UNC 场景。"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from ocskill import ( # noqa: E402
|
||||
diag_skill_md, skill_brief, probe_push, find_skill_md, _wsl_running,
|
||||
_alt_skill_md, _alt_skill_dirs, real_src,
|
||||
)
|
||||
import ocskill # noqa: E402
|
||||
|
||||
FAIL: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, extra: str = "") -> None:
|
||||
print(("PASS " if cond else "FAIL ") + name + (" | " + extra if extra else ""))
|
||||
if not cond:
|
||||
FAIL.append(name)
|
||||
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="ocskill_diag_")).resolve() # resolve 掉 8.3 短路径,否则 symlink 视角解析失败
|
||||
print("tmp =", tmp)
|
||||
|
||||
# 注册表隔离(2026-09-25 Round 10):skill.json 指到本轮临时文件并清空内存,
|
||||
# 否则上一轮测试落库的临时真身会被 real_src 的 tier① 内存命中,跨轮串味假失败。
|
||||
_orig_registry = (ocskill.REGISTRY_PATH, ocskill._REG)
|
||||
ocskill.REGISTRY_PATH = tmp / "skill.json"
|
||||
ocskill._REG = None
|
||||
|
||||
# ---------- 1. 空壳目录 ----------
|
||||
empty = tmp / "empty-skill"
|
||||
empty.mkdir()
|
||||
notes = diag_skill_md(empty)
|
||||
check("空壳目录 -> 说明是空的", any("目录是空的" in n for n in notes), str(notes))
|
||||
check("空壳目录 -> 结论仍未找到", any("仍未找到 SKILL.md" in n for n in notes), str(notes))
|
||||
b = skill_brief("empty-skill", tmp)
|
||||
check("skill_brief 空壳 -> has_md=False + diag", b.get("has_md") is False and b.get("diag"), str(b)[:200])
|
||||
|
||||
# ---------- 2. 正常 skill(含 SKILL.md) ----------
|
||||
real = tmp / "real-skill"
|
||||
real.mkdir()
|
||||
(real / "SKILL.md").write_text("---\nname: real-skill\ndescription: 测试用\ntest\n---\n", encoding="utf-8")
|
||||
b = skill_brief("real-skill", tmp)
|
||||
check("正常 skill -> has_md=True, diag 空", b.get("has_md") is True and b.get("diag") == [], str(b)[:200])
|
||||
notes = diag_skill_md(real)
|
||||
check("正常 skill 诊断 -> 无结论行", not any("仍未找到 SKILL.md" in n for n in notes), str(notes))
|
||||
|
||||
# ---------- 3. 目录链接 -> 真实 skill ----------
|
||||
# 本机 os.symlink 会「返回成功却不落盘」(防御软件拦截),创建后必须 lexists 验真,
|
||||
# 没落盘就换 junction(mklink /J 不需要特权,本机实测可用)
|
||||
link = tmp / "link-skill"
|
||||
made = ""
|
||||
try:
|
||||
os.symlink(str(real), str(link), target_is_directory=True)
|
||||
except OSError:
|
||||
pass
|
||||
if os.path.lexists(link):
|
||||
made = "symlink"
|
||||
else:
|
||||
r = subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(real)],
|
||||
capture_output=True) # 输出是 GBK,不解码只看 returncode
|
||||
made = "junction" if (r.returncode == 0 and os.path.lexists(link)) else ""
|
||||
if made:
|
||||
check(f"{made} 目录链接 -> 诊断报真身", find_skill_md(link) is not None,
|
||||
str(diag_skill_md(link)))
|
||||
b = skill_brief("link-skill", tmp)
|
||||
check(f"{made} 目录链接 -> skill_brief has_md=True", b.get("has_md") is True, str(b)[:200])
|
||||
else:
|
||||
check("创建目录链接(symlink+junction 都失败)", False, "")
|
||||
|
||||
# ---------- 4. SKILL.md 本体是链接 ----------
|
||||
# 本机文件 symlink 也会「返回成功却不落盘」,lexists 验真,不满足就 SKIP
|
||||
fdir = tmp / "filelink-skill"
|
||||
fdir.mkdir()
|
||||
fmd = fdir / "SKILL.md"
|
||||
fmade = ""
|
||||
try:
|
||||
os.symlink(str(real / "SKILL.md"), str(fmd))
|
||||
except OSError:
|
||||
pass
|
||||
if os.path.lexists(fmd):
|
||||
fmade = "file-symlink"
|
||||
if fmade:
|
||||
b = skill_brief("filelink-skill", tmp)
|
||||
check(f"SKILL.md {fmade} -> has_md=True", b.get("has_md") is True, str(b)[:200])
|
||||
else:
|
||||
print("SKIP SKILL.md 文件链接(本机 symlink 被拦截;junction 只支持目录)")
|
||||
|
||||
# ---------- 5. 断链目录 ----------
|
||||
broken = tmp / "broken-skill"
|
||||
bmade = ""
|
||||
try:
|
||||
os.symlink(str(tmp / "nonexistent-target"), str(broken), target_is_directory=True)
|
||||
except OSError:
|
||||
pass
|
||||
if os.path.lexists(broken):
|
||||
bmade = "symlink"
|
||||
else:
|
||||
r = subprocess.run(["cmd", "/c", "mklink", "/J", str(broken), str(tmp / "nonexistent-target")],
|
||||
capture_output=True)
|
||||
bmade = "junction" if (r.returncode == 0 and os.path.lexists(broken)) else ""
|
||||
if bmade:
|
||||
notes = diag_skill_md(broken)
|
||||
check(f"{bmade} 断链 -> 诊断报断链", any("断" in n for n in notes), str(notes))
|
||||
else:
|
||||
print("SKIP 断链目录(创建失败)")
|
||||
|
||||
# ---------- 6. SKILL.md 断链 ----------
|
||||
mdir = tmp / "mdbroken-skill"
|
||||
mdir.mkdir()
|
||||
mdlink = mdir / "SKILL.md"
|
||||
try:
|
||||
os.symlink(str(tmp / "nonexistent.md"), str(mdlink))
|
||||
except OSError:
|
||||
pass
|
||||
if os.path.lexists(mdlink):
|
||||
notes = diag_skill_md(mdir)
|
||||
check("SKILL.md 断链 -> 诊断报链接/断", any("SKILL.md 是链接" in n for n in notes), str(notes))
|
||||
else:
|
||||
print("SKIP SKILL.md 断链(本机文件 symlink 被拦截)")
|
||||
|
||||
# ---------- 7. 大小写不符(Windows 语义) ----------
|
||||
# Windows 文件系统不区分大小写:skill.md 也能被 SKILL.md 命中(真实可用);
|
||||
# 「大小写不符」诊断段只会在区分大小写的环境(WSL 内直读 / POSIX)走到
|
||||
casedir = tmp / "case-skill"
|
||||
casedir.mkdir()
|
||||
(casedir / "skill.md").write_text("---\nname: x\n---\n", encoding="utf-8")
|
||||
notes = diag_skill_md(casedir)
|
||||
check("skill.md 大小写(Windows 不区分)-> 直接命中", any("SKILL.md" in n for n in notes),
|
||||
str(notes))
|
||||
|
||||
# ---------- 8. WSL UNC:空壳目录(只读) ----------
|
||||
unc = Path(r"\\wsl.localhost\Ubuntu-24.04\root\.config\opencode\skills\java-expert")
|
||||
if unc.exists():
|
||||
notes = diag_skill_md(unc)
|
||||
check("WSL UNC 空壳 -> 报空的/仍未找到", any(("目录是空的" in n) or ("仍未找到" in n) for n in notes),
|
||||
str(notes))
|
||||
if _wsl_running("Ubuntu-24.04"):
|
||||
check("WSL UNC 空壳 -> 二层已查有说明行(不静默)",
|
||||
any("WSL 内二层" in n for n in notes), str(notes))
|
||||
else:
|
||||
print("SKIP WSL UNC 空壳(路径不存在):", unc)
|
||||
|
||||
# ---------- 9. WSL UNC:WSL 内真实链接(在 /root 下造,测完删) ----------
|
||||
setup = subprocess.run(
|
||||
["wsl.exe", "-d", "Ubuntu-24.04", "--", "sh", "-c",
|
||||
"rm -rf /root/_diagtest && mkdir -p /root/_diagtest/real && "
|
||||
"printf '%s\\n' '---' 'name: diagtest' 'description: d' 'test' '---' > /root/_diagtest/real/SKILL.md && "
|
||||
"ln -s /root/_diagtest/real /root/_diagtest/link && "
|
||||
"ln -s /root/_diagtest/nope /root/_diagtest/broken && ls -la /root/_diagtest"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
print("wsl setup rc =", setup.returncode, "|", (setup.stdout or "").strip()[:300])
|
||||
if setup.returncode == 0:
|
||||
running = _wsl_running("Ubuntu-24.04")
|
||||
print("wsl running =", running)
|
||||
unclink = Path(r"\\wsl.localhost\Ubuntu-24.04\root\_diagtest\link")
|
||||
uncbroken = Path(r"\\wsl.localhost\Ubuntu-24.04\root\_diagtest\broken")
|
||||
notes = diag_skill_md(unclink)
|
||||
if running:
|
||||
check("WSL 内链接(UNC 视角)-> 二层解出真身",
|
||||
any(("WSL 内找到 SKILL.md" in n) or ("WSL 内链接" in n) for n in notes),
|
||||
str(notes))
|
||||
# 好链绝不能被误报成「链接可能已断」
|
||||
check("WSL 内好链 -> 不误报断链", not any("链接可能已断" in n for n in notes), str(notes))
|
||||
else:
|
||||
print("SKIP WSL 内链接真身(VM 未运行,④层按设计跳过):", str(notes)[:200])
|
||||
check("VM 未运行 -> 给出跳过提示", any("未运行" in n or "交由 WSL" in n for n in notes),
|
||||
str(notes))
|
||||
notes = diag_skill_md(uncbroken)
|
||||
check("WSL 内断链(UNC 视角)-> 报断/结论", any(("断" in n) or ("仍未找到" in n) for n in notes),
|
||||
str(notes))
|
||||
subprocess.run(["wsl.exe", "-d", "Ubuntu-24.04", "--", "sh", "-c", "rm -rf /root/_diagtest"],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
|
||||
# ---------- 10. probe_push:对空壳目录推送 -> 报错带 diag ----------
|
||||
res = probe_push(str(empty))
|
||||
check("probe_push 空壳 -> ok=False 且带 diag", res.get("ok") is False and res.get("diag"),
|
||||
str(res)[:300])
|
||||
|
||||
# ---------- 11. 空壳但同名 skill 真身在别的根(2026-09-25 用户反馈) ----------
|
||||
# 真实场景:来源目录是空壳,但大模型能调用 —— 真身在 ~/.workbuddy/skills(wb 终端)。
|
||||
# 测试用 OC_SKILL_ALT_ROOTS 注入一个「另一个根」,避免往真实 wb 目录里写文件。
|
||||
altbase = tmp / "alt_roots"
|
||||
(altbase / "alt-skill").mkdir(parents=True)
|
||||
(altbase / "alt-skill" / "SKILL.md").write_text("---\nname: alt-skill\ndescription: d\nt\n---\n",
|
||||
encoding="utf-8")
|
||||
shell2 = tmp / "alt-skill"
|
||||
shell2.mkdir()
|
||||
os.environ["OC_SKILL_ALT_ROOTS"] = str(altbase)
|
||||
try:
|
||||
notes = diag_skill_md(shell2)
|
||||
finally:
|
||||
os.environ.pop("OC_SKILL_ALT_ROOTS", None)
|
||||
check("空壳 + 其它根有真身 -> 结论给出真身路径",
|
||||
any("真身在别处" in n for n in notes)
|
||||
and any(str(altbase / "alt-skill" / "SKILL.md") in n for n in notes),
|
||||
str(notes))
|
||||
b = skill_brief("alt-skill", tmp)
|
||||
check("skill_brief 空壳仍 has_md=False(真身只在诊断里提示)",
|
||||
b.get("has_md") is False and b.get("diag"), str(b)[:200])
|
||||
|
||||
# ---------- 12. 跨端/回收站兜底(环境依赖,不满足就 SKIP) ----------
|
||||
gkd = Path.home() / ".config" / "opencode" / "skills" / "gkd-shizuku-keepalive"
|
||||
gkd_alts = _alt_skill_md(gkd) if gkd.is_dir() and find_skill_md(gkd) is None else []
|
||||
if gkd_alts:
|
||||
notes = diag_skill_md(gkd)
|
||||
check("本机空壳 -> 找到回收站/跨端里的真身",
|
||||
any("真身在别处" in n for n in notes) and any(gkd_alts[0] in n for n in notes),
|
||||
str(notes))
|
||||
else:
|
||||
print("SKIP 跨端回收站兜底(环境不满足:目录不存在/非空壳/无处可找)")
|
||||
|
||||
# ---------- 13. 推送自动切换真身 real_src(2026-09-25 Round 9 用户要求) ----------
|
||||
# 空壳(oc 视角)+ 真身在另一个根(wb 视角)-> 自动切;有效目录 / 文件输入 -> 不动
|
||||
sw_real = tmp / "sw_roots" / "auto-switch-skill"
|
||||
sw_real.mkdir(parents=True)
|
||||
(sw_real / "SKILL.md").write_text(
|
||||
"---\nname: auto-switch-skill\ndescription: d\nt\n---\n", encoding="utf-8")
|
||||
sw_shell = tmp / "auto-switch-skill"
|
||||
sw_shell.mkdir()
|
||||
os.environ["OC_SKILL_ALT_ROOTS"] = str(tmp / "sw_roots")
|
||||
try:
|
||||
got = real_src(str(sw_shell))
|
||||
got_self = real_src(str(sw_real))
|
||||
got_file = real_src(str(sw_real / "SKILL.md"))
|
||||
dirs = _alt_skill_dirs(str(sw_shell))
|
||||
finally:
|
||||
os.environ.pop("OC_SKILL_ALT_ROOTS", None)
|
||||
check("空壳 -> real_src 自动切到真身目录",
|
||||
got is not None and os.path.normcase(os.path.abspath(got))
|
||||
== os.path.normcase(os.path.abspath(str(sw_real))), str(got))
|
||||
check("有效目录 -> real_src 不切换(None)", got_self is None, str(got_self))
|
||||
check("SKILL.md 文件输入 -> 不参与切换(None)", got_file is None, str(got_file))
|
||||
check("_alt_skill_dirs 返回真身目录(不只 SKILL.md)",
|
||||
any(os.path.normcase(os.path.abspath(d))
|
||||
== os.path.normcase(os.path.abspath(str(sw_real))) for d in dirs), str(dirs))
|
||||
# junction 链接(Windows;防御软件可能拦,mklink 失败就 SKIP)
|
||||
sw_link = tmp / "auto-switch-link"
|
||||
proc = subprocess.run(["cmd", "/c", "mklink", "/J", str(sw_link), str(sw_real)],
|
||||
capture_output=True, text=True, errors="replace", timeout=15)
|
||||
if proc.returncode == 0 and sw_link.is_dir():
|
||||
got_l = real_src(str(sw_link))
|
||||
check("junction 链接 -> real_src 切到链接目标",
|
||||
got_l is not None and os.path.normcase(os.path.abspath(got_l))
|
||||
== os.path.normcase(os.path.abspath(str(sw_real))), str(got_l))
|
||||
else:
|
||||
print("SKIP junction 链接真身(mklink 失败,防御软件拦截):",
|
||||
((proc.stdout or "") + (proc.stderr or "")).strip()[:120])
|
||||
|
||||
print()
|
||||
print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL)))
|
||||
sys.exit(1 if FAIL else 0)
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""mcpctl 热重载前置探活单测(2026-09-25 用户反馈)。
|
||||
|
||||
用户场景:mcp↔wsl 开关对调后,WSL/OC 没在跑 → 报「热重载失败:… WinError 10061
|
||||
连接拒绝」。期望:没有可重载的 OC 就跳过重载、按成功返回(下次冷启动自动加载),
|
||||
只有「OC 在跑但重载出错」才算失败。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import mcpctl # noqa: E402
|
||||
|
||||
FAIL: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, extra: str = "") -> None:
|
||||
print(("PASS " if cond else "FAIL ") + name + (" | " + extra if extra else ""))
|
||||
if not cond:
|
||||
FAIL.append(name)
|
||||
|
||||
|
||||
class _A: # 最小 args 替身(只用到 _live_oc/do_reload 需要的字段)
|
||||
distro = "Ubuntu-24.04"
|
||||
password = ""
|
||||
port = 4096
|
||||
timeout = 2.0
|
||||
|
||||
|
||||
args = _A()
|
||||
_orig = (mcpctl._wsl_running, mcpctl._find_live_host, mcpctl._post)
|
||||
_orig_cand = mcpctl._candidate_hosts
|
||||
|
||||
|
||||
def _no_net(*_a, **_k):
|
||||
raise AssertionError("这一步不该做网络探测")
|
||||
|
||||
|
||||
try:
|
||||
# ---------- 1. WSL 目标 + VM 未运行 -> 直接 skip,绝不探测 ----------
|
||||
mcpctl._wsl_running = lambda d: False
|
||||
mcpctl._find_live_host = _no_net
|
||||
h, pwd, skip = mcpctl._live_oc("wsl", args)
|
||||
check("WSL 未运行 -> skip 且不做探测",
|
||||
h == "" and bool(skip) and "WSL 未启动" in skip, str(skip))
|
||||
|
||||
# ---------- 2. WSL 在跑但 OC 没在跑 -> skip 带探测原因(不再是「失败」) ----------
|
||||
mcpctl._wsl_running = lambda d: True
|
||||
mcpctl._find_live_host = (
|
||||
lambda t, a, pw: ("", "/root/.config/opencode", "http://172.29.95.185:4096 连不上")
|
||||
)
|
||||
h, pwd, skip = mcpctl._live_oc("wsl", args)
|
||||
check("OC 未运行 -> skip 带原因", h == "" and bool(skip) and "连不上" in skip, str(skip))
|
||||
|
||||
# ---------- 3. OC 在跑 -> 不 skip,host 直接复用 ----------
|
||||
mcpctl._find_live_host = (
|
||||
lambda t, a, pw: ("http://127.0.0.1:4096", "/root/.config/opencode", "")
|
||||
)
|
||||
h, pwd, skip = mcpctl._live_oc("wsl", args)
|
||||
check("OC 运行中 -> 不 skip 且拿到 host",
|
||||
skip is None and h.startswith("http"), str((h, skip)))
|
||||
|
||||
# ---------- 4. do_reload 复用 host -> 不再探测,直接 reload 成功 ----------
|
||||
mcpctl._find_live_host = _no_net
|
||||
mcpctl._post = lambda url, pw, timeout, data=b"{}", method="POST": (200, "{}")
|
||||
ok, msg = mcpctl.do_reload("wsl", args, host="http://127.0.0.1:4096", password="x")
|
||||
check("do_reload 复用 host -> 热重载成功", ok and "热重载成功" in msg, msg)
|
||||
|
||||
# ---------- 5. OC 在跑但 reload 返回错误 -> 仍算真实失败(不能吞) ----------
|
||||
mcpctl._post = lambda url, pw, timeout, data=b"{}", method="POST": (0, "boom")
|
||||
ok, msg = mcpctl.do_reload("wsl", args, host="http://127.0.0.1:4096", password="x")
|
||||
check("OC 在跑但重载连不上 -> 仍报失败", (not ok) and "连不上" in msg, msg)
|
||||
|
||||
# ---------- 6. 本机目标 OC 没在跑 -> skip(本机目标不看 WSL 状态) ----------
|
||||
mcpctl._find_live_host = (
|
||||
lambda t, a, pw: ("", "C:/cfg", "没有找到运行中的 OC(4096 未监听,端口发现也未命中)")
|
||||
)
|
||||
h, pwd, skip = mcpctl._live_oc("local", args)
|
||||
check("local OC 未运行 -> skip", h == "" and bool(skip) and "没有找到" in skip, str(skip))
|
||||
|
||||
# ---------- 7. 探活措辞(2026-09-25 Round 9):OC 没启动要说「未启动」 ----------
|
||||
# 真实 _find_live_host + 假 _post:10061 拒绝不许再写成吓人的「连不上」
|
||||
mcpctl._find_live_host = _orig[1]
|
||||
mcpctl._candidate_hosts = lambda t, a: ["http://172.29.95.185:4096"]
|
||||
mcpctl._post = lambda *ar, **kw: (
|
||||
0, "<urlopen error [WinError 10061] 由于目标计算机积极拒绝,无法连接。>")
|
||||
h, _d, why = mcpctl._find_live_host("wsl", args, "pw")
|
||||
check("10061 拒绝 -> 措辞「OC 未启动」且不含「连不上」",
|
||||
h == "" and "OC 未启动" in why and "连不上" not in why, why)
|
||||
|
||||
# ---------- 8. 超时 -> 「连接超时(OC 未启动或不可达)」 ----------
|
||||
mcpctl._post = lambda *ar, **kw: (
|
||||
0, "<urlopen error [WinError 10060] 连接方在一段时间后没有正确答复>")
|
||||
h, _d, why = mcpctl._find_live_host("wsl", args, "pw")
|
||||
check("10060 超时 -> 措辞「连接超时」", h == "" and "超时" in why, why)
|
||||
|
||||
# ---------- 9. 未知网络错误 -> 保留原文(真实错误不能吞) ----------
|
||||
mcpctl._post = lambda *ar, **kw: (0, "weird backend exploded")
|
||||
h, _d, why = mcpctl._find_live_host("wsl", args, "pw")
|
||||
check("未知错误 -> 保留「连不上」原文", h == "" and "连不上" in why, why)
|
||||
|
||||
# ---------- 10. skip 端到端:_live_oc 拼出的整句都不该像报错 ----------
|
||||
mcpctl._wsl_running = lambda d: True
|
||||
mcpctl._post = lambda *ar, **kw: (
|
||||
0, "<urlopen error [WinError 10061] 由于目标计算机积极拒绝,无法连接。>")
|
||||
h, pwd, skip = mcpctl._live_oc("wsl", args)
|
||||
check("skip 整句 = 状态描述(含「OC 未启动」,不含「连不上」)",
|
||||
h == "" and bool(skip) and "OC 未启动" in skip and "连不上" not in skip,
|
||||
str(skip))
|
||||
finally:
|
||||
mcpctl._wsl_running, mcpctl._find_live_host, mcpctl._post = _orig
|
||||
mcpctl._candidate_hosts = _orig_cand
|
||||
|
||||
print()
|
||||
print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL)))
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -1,223 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""skill.json 注册表单测:内存加载 / 落库 / 内嵌常见根 / 分层查找 / 后台扫盘。
|
||||
|
||||
覆盖 2026-09-25 需求:推拉遇软链接空壳 -> 先查内存注册表 -> 再快扫内嵌常见
|
||||
终端位置(oc/wb/cc-switch…)-> 都没中才后台扫盘(不阻塞),扫完落库 skill.json
|
||||
(名字 -> 位置 的一堆键值);用户选定的有效目录永远优先(real_src 返回 None)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ocskill # noqa: E402
|
||||
|
||||
FAIL: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, extra: str = "") -> None:
|
||||
print(("PASS " if cond else "FAIL ") + name + (" | " + extra if extra else ""))
|
||||
if not cond:
|
||||
FAIL.append(name)
|
||||
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="ocskill_reg_")).resolve()
|
||||
print("tmp =", tmp)
|
||||
|
||||
_orig = { # 测完还原,别污染同进程后续用例
|
||||
"REGISTRY_PATH": ocskill.REGISTRY_PATH,
|
||||
"_REG": ocskill._REG,
|
||||
"env_alt": os.environ.get("OC_SKILL_ALT_ROOTS"),
|
||||
"builtin": ocskill.builtin_skill_roots,
|
||||
"deep": ocskill.deep_scan_roots,
|
||||
"bg": ocskill.start_background_scan,
|
||||
}
|
||||
|
||||
|
||||
def reset_registry(path: Path) -> None:
|
||||
ocskill.REGISTRY_PATH = path
|
||||
ocskill._REG = None # 强制下次 load_registry 重读
|
||||
|
||||
|
||||
def mk_skill(folder: Path, name: str) -> Path:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: 测试技能\n---\n# {name}\n",
|
||||
encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
try:
|
||||
# ---------- 1. 空表起步 + remember + 落库 + 启动重读入内存 ----------
|
||||
reset_registry(tmp / "skill.json")
|
||||
reg = ocskill.load_registry()
|
||||
check("1 空表起步 skills={}", reg.get("skills") == {}, str(reg))
|
||||
real_a = mk_skill(tmp / "roots" / "A" / "foo-skill", "foo-skill")
|
||||
ocskill.remember_skills({"foo-skill": [str(real_a)]})
|
||||
check("1 remember 进内存", ocskill.registry_locations("foo-skill") == [str(real_a)],
|
||||
str(ocskill.registry_locations("foo-skill")))
|
||||
check("1 remember 落库文件存在", (tmp / "skill.json").is_file())
|
||||
data = json.loads((tmp / "skill.json").read_text(encoding="utf-8"))
|
||||
check("1 落库结构 = version/updated/skills",
|
||||
{"version", "updated", "skills"} <= set(data.keys())
|
||||
and data["skills"].get("foo-skill") == [str(real_a)], str(data)[:200])
|
||||
ocskill._REG = None # 模拟下次启动
|
||||
reg2 = ocskill.load_registry()
|
||||
check("1 启动重读入内存(skill.json -> 内存)",
|
||||
reg2.get("skills", {}).get("foo-skill") == [str(real_a)], str(reg2)[:200])
|
||||
# 重复 remember 不产生重复条目
|
||||
ocskill.remember_skills({"foo-skill": [str(real_a)]})
|
||||
check("1 remember 幂等(不重复)",
|
||||
ocskill.registry_locations("foo-skill") == [str(real_a)])
|
||||
# 损坏文件不炸:从空表起步
|
||||
(tmp / "skill.json").write_text("{broken json", encoding="utf-8")
|
||||
ocskill._REG = None
|
||||
reg3 = ocskill.load_registry()
|
||||
check("1 损坏 skill.json -> 空表起步不炸", reg3.get("skills") == {}, str(reg3))
|
||||
|
||||
# ---------- 2. 内嵌常见终端根 ----------
|
||||
roots = ocskill.builtin_skill_roots()
|
||||
home = Path.home()
|
||||
for tag, expect in [
|
||||
("oc", home / ".config" / "opencode" / "skills"),
|
||||
("wb", home / ".workbuddy" / "skills"),
|
||||
("cc-switch", home / ".cc-switch" / "skills"),
|
||||
("claude", home / ".claude" / "skills"),
|
||||
]:
|
||||
key = os.path.normcase(os.path.abspath(str(expect)))
|
||||
check(f"2 内嵌 {tag} 位置", key in [os.path.normcase(os.path.abspath(r)) for r in roots],
|
||||
str(expect))
|
||||
agent_dirs = [os.path.normcase(os.path.abspath(t["dir"]))
|
||||
for t in ocskill.load_agent_table() if t.get("dir")]
|
||||
got = [os.path.normcase(os.path.abspath(r)) for r in roots]
|
||||
check("2 agents.md 终端目录纳入", all(d in got for d in agent_dirs),
|
||||
f"agents={agent_dirs}")
|
||||
check("2 内嵌根无重复", len(got) == len(set(got)), str(len(got)))
|
||||
check("2 env 追加根", True) # 用例 3 实际验证
|
||||
|
||||
# ---------- 3. tier1 快扫 scan_roots_for_name + env 追加 ----------
|
||||
root_b = tmp / "roots" / "B"
|
||||
target = mk_skill(root_b / "quick-skill", "quick-skill")
|
||||
found = ocskill.scan_roots_for_name("quick-skill", roots=[str(root_b)])
|
||||
check("3 快扫按名字命中真身", found == [str(target)], str(found))
|
||||
check("3 快扫找不到时返回空", ocskill.scan_roots_for_name("nope-xyz", roots=[str(root_b)]) == [])
|
||||
# env 追加的根也进 builtin
|
||||
os.environ["OC_SKILL_ALT_ROOTS"] = str(root_b)
|
||||
roots_env = ocskill.builtin_skill_roots()
|
||||
check("2 env 追加根进 builtin",
|
||||
os.path.normcase(os.path.abspath(str(root_b))) in
|
||||
[os.path.normcase(os.path.abspath(r)) for r in roots_env], str(roots_env)[:200])
|
||||
|
||||
# ---------- 4. real_src 分层:① 内存注册表命中(零扫盘) ----------
|
||||
reset_registry(tmp / "skill2.json")
|
||||
ocskill.remember_skills({"foo-skill": [str(real_a)]})
|
||||
shell1 = tmp / "shell" / "foo-skill"
|
||||
shell1.mkdir(parents=True) # 空壳:目录在、没有 SKILL.md
|
||||
bg_called: list[str] = []
|
||||
ocskill.start_background_scan = lambda name="": bg_called.append(name) or True
|
||||
real = ocskill.real_src(str(shell1))
|
||||
check("4 空壳 -> 内存注册表一步命中", real == str(real_a), f"real={real}")
|
||||
check("4 注册表命中不触发后台扫盘", bg_called == [], str(bg_called))
|
||||
|
||||
# ---------- 5. real_src 分层:② tier1 快扫命中并落库 ----------
|
||||
shell2 = tmp / "shell" / "quick-skill"
|
||||
shell2.mkdir(parents=True)
|
||||
ocskill._REG = None # 空注册表,逼走 tier1
|
||||
real2 = ocskill.real_src(str(shell2))
|
||||
check("5 空壳 -> 快扫内嵌常见位置命中", real2 == str(target), f"real={real2}")
|
||||
check("5 快扫命中即落库",
|
||||
"quick-skill" in ocskill.load_registry().get("skills", {}),
|
||||
str(ocskill.load_registry().get("skills", {}))[:200])
|
||||
check("5 快扫命中不触发后台扫盘", bg_called == [], str(bg_called))
|
||||
|
||||
# ---------- 6. real_src 分层:③ 都没中 -> 启动后台扫盘(不阻塞返回 None) ----------
|
||||
shell3 = tmp / "shell" / "ghost-skill"
|
||||
shell3.mkdir(parents=True)
|
||||
ocskill._REG = None
|
||||
os.environ["OC_SKILL_ALT_ROOTS"] = str(tmp / "nowhere") # 指到不存在的根
|
||||
real3 = ocskill.real_src(str(shell3))
|
||||
check("6 没真身 -> 返回 None(走原诊断路径)", real3 is None, str(real3))
|
||||
check("6 没真身 -> 启动后台扫盘", bg_called == ["ghost-skill"], str(bg_called))
|
||||
|
||||
# ---------- 7. 后台扫盘:单飞 + 扫完落库 skill.json ----------
|
||||
ocskill.start_background_scan = _orig["bg"] # 还原真身
|
||||
root_c = tmp / "roots" / "C"
|
||||
mk_skill(root_c / "bg-skill-1", "bg-skill-1")
|
||||
mk_skill(root_c / "bg-skill-2", "bg-skill-2")
|
||||
(root_c / ".hidden-skill").mkdir() # 点前缀跳过
|
||||
(root_c / "no-md-skill").mkdir() # 无 SKILL.md 跳过
|
||||
reset_registry(tmp / "skill3.json")
|
||||
ocskill.builtin_skill_roots = lambda distro=ocskill.DEFAULT_DISTRO: [str(root_c)]
|
||||
ocskill.deep_scan_roots = lambda: []
|
||||
# 放慢扫描以便验证单飞
|
||||
_real_scan = ocskill._scan_root_skills
|
||||
|
||||
def _slow_scan(root):
|
||||
time.sleep(0.4)
|
||||
return _real_scan(root)
|
||||
|
||||
ocskill._scan_root_skills = _slow_scan
|
||||
try:
|
||||
started = ocskill.start_background_scan("x")
|
||||
check("7 后台扫盘启动成功", started is True, str(started))
|
||||
again = ocskill.start_background_scan("y")
|
||||
check("7 后台扫盘单飞(运行中不叠加线程)", again is False, str(again))
|
||||
deadline = time.time() + 10
|
||||
while ocskill._BG_SCAN_RUNNING and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
check("7 后台扫盘完成", not ocskill._BG_SCAN_RUNNING)
|
||||
finally:
|
||||
ocskill._scan_root_skills = _real_scan
|
||||
skills = ocskill.load_registry().get("skills", {})
|
||||
check("7 扫盘结果进内存注册表",
|
||||
"bg-skill-1" in skills and "bg-skill-2" in skills, str(list(skills)))
|
||||
check("7 点前缀 / 无 SKILL.md 不登记",
|
||||
"no-md-skill" not in skills and ".hidden-skill" not in skills, str(list(skills)))
|
||||
data3 = json.loads((tmp / "skill3.json").read_text(encoding="utf-8"))
|
||||
check("7 扫盘结果落库 skill.json", "bg-skill-1" in data3.get("skills", {}),
|
||||
str(data3)[:200])
|
||||
|
||||
# ---------- 8. 用户选定的有效目录优先(real_src 返回 None 不动) ----------
|
||||
valid = mk_skill(tmp / "valid-skill", "valid-skill")
|
||||
ocskill._REG = None
|
||||
check("8 有效目录不动(选定优先)", ocskill.real_src(str(valid)) is None, str(valid))
|
||||
# zip / SKILL.md 文件输入也不动
|
||||
mdfile = valid / "SKILL.md"
|
||||
check("8 SKILL.md 文件不动", ocskill.real_src(str(mdfile)) is None, str(mdfile))
|
||||
|
||||
# ---------- 9. deep_scan_roots 浅层扫描 ----------
|
||||
old_cwd = os.getcwd()
|
||||
work = tmp / "cwd"
|
||||
(work / "someapp" / "skills").mkdir(parents=True)
|
||||
try:
|
||||
os.chdir(work)
|
||||
deep = _orig["deep"]() # 用原函数(case7 把它补丁成了 lambda: [])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
key = os.path.normcase(os.path.abspath(str(work / "someapp" / "skills")))
|
||||
check("9 扩展根扫到 <cwd>/someapp/skills",
|
||||
key in [os.path.normcase(os.path.abspath(d)) for d in deep], str(deep))
|
||||
check("9 扩展根无重复",
|
||||
len([os.path.normcase(os.path.abspath(d)) for d in deep]) ==
|
||||
len(set(os.path.normcase(os.path.abspath(d)) for d in deep)), str(len(deep)))
|
||||
|
||||
finally:
|
||||
# ---------- 还原现场 ----------
|
||||
ocskill.REGISTRY_PATH = _orig["REGISTRY_PATH"]
|
||||
ocskill._REG = _orig["_REG"]
|
||||
ocskill.builtin_skill_roots = _orig["builtin"]
|
||||
ocskill.deep_scan_roots = _orig["deep"]
|
||||
ocskill.start_background_scan = _orig["bg"]
|
||||
if _orig["env_alt"] is None:
|
||||
os.environ.pop("OC_SKILL_ALT_ROOTS", None)
|
||||
else:
|
||||
os.environ["OC_SKILL_ALT_ROOTS"] = _orig["env_alt"]
|
||||
|
||||
print()
|
||||
if FAIL:
|
||||
print(f"{len(FAIL)} FAILED: {FAIL}")
|
||||
sys.exit(1)
|
||||
print("ALL PASS")
|
||||
@@ -1,26 +0,0 @@
|
||||
# agent 终端 skill 目录表
|
||||
|
||||
拉取 / 推送 skill 时用「简称」定位目标终端。每行一个终端,两列:
|
||||
|
||||
| 名字(全称) | skill 目录 |
|
||||
|
||||
## 规则(宽松解析,不校验 markdown 完整性)
|
||||
|
||||
- 取「以 `|` 开头」的行,按 `|` 拆分取前两个非空字段;表格没闭合、少分隔符、行首多空格都照样认。
|
||||
(行首不是 `|` 的行一律不算表格行——本说明里提到的 `|` 不会混进配置。)
|
||||
- 第一列 = 名字:括号前是**简称**(CLI 输入前缀即可,如 `wb`),括号内是**全称**(CLI 打印用)。
|
||||
半角 `()` 与全角 `()` 都认,任何形式的括号都忽略。
|
||||
- 第二列 = skill 目录绝对路径。
|
||||
- **新增一行表格 = 新增一个 agent 终端**,保存即生效,不用改代码。
|
||||
- 自动跳过:表头行(第一列/第二列命中 名字、名称、name 与 目录、路径、dir、path 关键词)、
|
||||
分隔行(字段全是 `-` / `:`)、不含 `|` 的行、缺目录的行。
|
||||
- 拉取 skill 列表时直接读该目录,**不启动 OC 服务**。
|
||||
|
||||
## 内置终端
|
||||
|
||||
| 名字 | 目录 |
|
||||
| --- | --- |
|
||||
| wb(workbuddy) | C:\Users\ypshidifu\.workbuddy\skills |
|
||||
| oc(opencode) | C:\Users\ypshidifu\.config\opencode\skills |
|
||||
|
||||
> 另有内置默认终端 `wsl`(WSL 里的 OC skill 目录):来源/目标留空回车即选它,不在本表内。
|
||||
@@ -0,0 +1,345 @@
|
||||
// compaction-cn —— OC 对话压缩摘要中文化 server 插件
|
||||
//
|
||||
// 背景与动机:
|
||||
// OC 原生压缩(SessionCompaction)提示词硬编码在 opencode.exe 二进制内,
|
||||
// opencode.config.compaction 配置 schema 只有 {auto, buffer, keep.tokens},无 prompt 覆盖字段;
|
||||
// V1 插件 hooks(含官方文档的 experimental.session.compacting)在 OC 2.0.15 V2 运行时
|
||||
// 【没有任何消费者】(实测:default 对象上的 V1 hook 字段零调用,且 named export 返回
|
||||
// hooks 对象会被当 cleanup 调用 → TypeError: Ae is not a function 整批崩)。
|
||||
// 于是压缩摘要默认产出纯英文,后续任务容易被前面的英文摘要带偏。
|
||||
//
|
||||
// ── OC 2.0.15 压缩链路(逆向 opencode.exe 实锤,勿重复考古)────────────────
|
||||
// auto = SessionCompaction.compact(k) reason:"auto" (runStep 里 required:F 判定后调用)
|
||||
// manual = SessionCompaction.compactManual(T) reason:"manual"
|
||||
// 两者都进 G(execute) → g(N,msg) → N.prepare(args)
|
||||
// → SessionModelRequest.prepare → trigger("session","compaction", payload)
|
||||
// (trigger:顺序执行注册回调、返回【同一个 payload 对象】⇒ 对 system/messages 的原地 push 必然生效)
|
||||
// → T.system / T.messages → e9({system:T.system, messages:NB(T6(T.messages))}) → 构造 request
|
||||
// G 在 prepare 返回【之后】才拼压缩模板:
|
||||
// be = pn.update(we.request, {messages:[...we.request.messages, Zt.user(z$(...))]})
|
||||
// ⇒ 钩子里追加的消息一定排在模板【前面】。
|
||||
//
|
||||
// ── 2026-09-26 根因(A/B 实测,模型 agnes-2.5-flash)──────────────────────
|
||||
// 注入生效但位置不对。同一模型四组对照(对话全英文、模板一致):
|
||||
// A. 中文规则放 system 末尾 → cn=0 纯英文(= 插件 v1/v2 的做法)
|
||||
// B. 中文指令放【模板之后】 → cn=157 中文 ✅
|
||||
// C. 中文规则放 system 最前面 → cn=0 纯英文
|
||||
// D. 模板之后追加、用英文措辞 → cn=123 中文 ✅
|
||||
// ⇒ 模型只认「最后一条消息」里的指令;system 段无论放哪都压不住。
|
||||
// ⇒ 手动压缩出中文,是因为对话本身就是中文(模型跟随对话语言),与注入无关;
|
||||
// 自动压缩时上下文几乎全英文,才暴露出「指令位置不对」。
|
||||
//
|
||||
// ── 因此干预点分两层(v4)────────────────────────────────────────────────
|
||||
// ① session.hook("compaction"):向 payload.system 追加完整中文规则(覆盖读 system 的模型)
|
||||
// ② session.hook("http.request"):【关键】压缩请求出网前解析 JSON body,
|
||||
// 检测到压缩模板(## Objective + Do not continue the task)后做两件事:
|
||||
// ②-a 把极短英文语言规则【内联写进模板消息本身】(紧跟 Do not continue the task)
|
||||
// ②-b 再在 messages 【最末尾】(即模板之后)追加一条独立指令
|
||||
// 2026-09-26 实测只做 ②-b 时 4 次里有 1 次模型仍输出英文,故 v4 加 ②-a 双保险。
|
||||
// 非压缩请求 / 非 JSON / 已含标记 → 原样放行,绝不改写。
|
||||
//
|
||||
// ── v5(2026-10-12):常驻中文引导 ──────────────────────────────────────
|
||||
// 需求:模型在【每次工具调用后、思考结束、任何时候】都保持中文回复,
|
||||
// 不被工具返回的英文结果/日志/网页内容带偏;模型确实不支持中文则可忽略。
|
||||
// 做法:http.request 钩子扩展到【所有 kind】的 JSON 聊天请求——
|
||||
// * 压缩请求(指纹命中)→ 走原 v4 双保险逻辑(不变)
|
||||
// * 其它请求 → 在 messages 【最末尾】追加常驻规则 <reply-language>
|
||||
// (A/B 实测:该模型只认 messages 最末尾的指令;每轮请求 hook 重新注入,
|
||||
// 历史不落库,因此每轮都是新注入,天然"任何时候"生效)
|
||||
// 幂等标记 LANG_MARK 与压缩标记 MARK 分开,互不干扰;
|
||||
// 日志按 sessionID 60s 节流,避免刷屏。
|
||||
//
|
||||
// 参考:session.hook("model.request") 实测 payload keys 只有
|
||||
// ["sessionID","agent","model","kind","baseURL","headers"],无 system/messages,
|
||||
// 只能做诊断日志,改不了内容(保留日志便于日后核对)。
|
||||
//
|
||||
// 关键约束(不能违反):
|
||||
// 输出校验 ww = (e) => e.split("\n").some(a => U$.includes(a.trim()))
|
||||
// U$ = 模板按行过滤出的英文标题(## Objective / ## Requirements / ## Decisions /
|
||||
// ## Work State / ### Completed / ### Active / ### Blocked / ## Next Move /
|
||||
// ## Relevant Files / ## Important Context)。
|
||||
// ⇒ 摘要必须含【整行英文标题】,翻译标题 → 校验失败 → 压缩失败。
|
||||
// ⇒ 只能要求「内容中文、标题原样英文」,绝不能翻译标题。
|
||||
// compaction 钩子里若追加消息,content 必须是数组:
|
||||
// prepare 链路里 T6 会在 Zt.make 之前对 content 调 .map(),给字符串会 TypeError。
|
||||
//
|
||||
// 与 ai-mcp 同款的 V2 插件契约(勿破坏):
|
||||
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
|
||||
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
|
||||
//
|
||||
// 部署(三份同步 + node --experimental-strip-types 校验):
|
||||
// 源码真源 : D:\桌面\源码\oc_session\compaction-cn\compaction-cn.ts
|
||||
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\compaction-cn.ts
|
||||
// WSL 生效 : /root/.config/opencode/plugins/compaction-cn.ts
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
const LOG_DIR = join(homedir(), ".config", "opencode")
|
||||
const LOG_FILE = join(LOG_DIR, "compaction-cn.log")
|
||||
|
||||
// 标记串:幂等判重用(多 location / 多次 reload 可能重复注册钩子)
|
||||
const MARK = "<compaction-language>"
|
||||
|
||||
// system 段注入(对部分模型有效,对只认最后一条消息的模型无效,保留但不作为唯一手段)
|
||||
const CN_RULE = `${MARK}
|
||||
以下为本次会话压缩(compaction)摘要的输出语言要求,优先级高于模板中的其他说明:
|
||||
|
||||
1. 除第 2 条外,摘要的全部内容必须使用简体中文撰写,包括各小节的条目正文、说明与结论。
|
||||
即使被压缩的对话原文是英文,摘要正文也必须用简体中文。
|
||||
2. 所有小节标题必须原样保留英文、不得翻译或改动大小写,包括:
|
||||
## Objective / ## Requirements / ## Decisions / ## Work State /
|
||||
### Completed / ### Active / ### Blocked /
|
||||
## Next Move / ## Relevant Files / ## Important Context
|
||||
这些标题行是压缩结果的格式校验依据,翻译或改写会导致本次压缩失败。
|
||||
3. 文件路径、代码、命令、URL、报错原文、标识符以及用户原话引用保持原样,不要翻译。
|
||||
4. 严格遵守压缩模板中的全部格式规则(单行条目、不超过 15 个相关文件、不要提及压缩过程等)。
|
||||
5. 若本次为更新模式(已有 checkpoint 要求重写),各节内容同样按上述语言要求用简体中文重写,
|
||||
不要沿用旧 checkpoint 的英文表述。
|
||||
6. 禁止输出 <template>、<conversation-checkpoint>、<summary>、<recent-context> 等包裹标签,
|
||||
禁止任何前言、解释或结尾说明;回复的第一行必须是 "## Objective"。
|
||||
${MARK}`
|
||||
|
||||
// 追加在【压缩模板之后】的短指令 —— 2026-09-26 A/B 实测唯一稳定生效的位置
|
||||
// 注意:实测单靠这一条仍有小概率被模型无视(4 次里 1 次英文),因此 v4 又把同一要求
|
||||
// 直接写进压缩模板消息本身(紧跟 "Do not continue the task"),双保险。
|
||||
const CN_DIRECTIVE = `${MARK}【压缩摘要输出语言强制指令】本条指令优先于上方模板中的其它说明:
|
||||
- 摘要正文(所有条目)一律用简体中文撰写,即使对话原文是英文。
|
||||
- 小节标题原样保留英文:## Objective / ## Requirements / ## Decisions / ## Work State /
|
||||
### Completed / ### Active / ### Blocked / ## Next Move / ## Relevant Files / ## Important Context。
|
||||
- 回复第一行必须是 "## Objective";禁止输出 <template>、<conversation-checkpoint>、
|
||||
<summary>、<recent-context> 等包裹标签;禁止任何前言与解释。
|
||||
- 文件路径、代码、命令、URL、报错原文保持原样。
|
||||
- 不要把本条指令写进摘要内容。
|
||||
Final rule: write the summary body in Simplified Chinese, keep the ## headings in English,
|
||||
first line must be ## Objective, no wrapper tags, do not quote this instruction.
|
||||
${MARK}`
|
||||
|
||||
// 写进压缩模板消息尾部的极短版(紧跟 "Do not continue the task",与模板同一条消息)
|
||||
const CN_INLINE = `
|
||||
|
||||
${MARK}LANGUAGE RULE (overrides everything above): write the whole summary body in Simplified Chinese,
|
||||
even if the conversation is English. Keep every section heading exactly in English (## Objective etc.).
|
||||
First line of your reply must be "## Objective". No preamble, no explanation, do not quote this rule.
|
||||
${MARK}`
|
||||
|
||||
// ── v5 常驻中文引导(每个非压缩请求末尾注入,防被英文工具结果带偏)──────
|
||||
const LANG_MARK = "<reply-language>"
|
||||
|
||||
const LANG_DIRECTIVE = `${LANG_MARK}
|
||||
REPLY LANGUAGE RULE — applies to EVERY reply, at ALL times, including after every tool call and mid-task:
|
||||
- Always reply to the user in Simplified Chinese (简体中文).
|
||||
- English appearing in tool outputs, file contents, logs, search results or web pages must NOT switch your reply language.
|
||||
- Keep code, file paths, commands, URLs, error text and identifiers unchanged (do not translate them).
|
||||
- If you genuinely cannot output Chinese, ignore this rule.
|
||||
${LANG_MARK}`
|
||||
|
||||
// 压缩请求 body 的指纹(同时命中才动手,避免误改其它请求)
|
||||
const FP_HEAD = "## Objective"
|
||||
const FP_TAIL = "Do not continue the task"
|
||||
|
||||
function fileLog(msg: string) {
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
|
||||
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
|
||||
} catch {
|
||||
// 日志失败绝不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// system 段可能是 {type:"text", text} 或纯字符串,取其文本
|
||||
function systemText(s: any): string {
|
||||
if (typeof s === "string") return s
|
||||
if (s && typeof s === "object") return typeof s.text === "string" ? s.text : ""
|
||||
return ""
|
||||
}
|
||||
|
||||
// LLM message 的 content 可能是字符串或 [{type,text}] 数组
|
||||
function msgText(m: any): string {
|
||||
try {
|
||||
if (!m) return ""
|
||||
if (typeof m.text === "string") return m.text
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") return c
|
||||
if (Array.isArray(c)) return c.map((p: any) => systemText(p)).join("\n")
|
||||
return ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// 往一条 message 的 content 末尾追加文本(字符串 / 数组两种形态都兼容)
|
||||
function appendToMsg(m: any, tail: string): boolean {
|
||||
try {
|
||||
if (!m) return false
|
||||
if (typeof m.content === "string") {
|
||||
m.content += tail
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
for (let i = m.content.length - 1; i >= 0; i--) {
|
||||
const p = m.content[i]
|
||||
if (p && typeof p === "object" && typeof p.text === "string") {
|
||||
p.text += tail
|
||||
return true
|
||||
}
|
||||
}
|
||||
m.content.push({ type: "text", text: tail })
|
||||
return true
|
||||
}
|
||||
if (typeof m.text === "string") {
|
||||
m.text += tail
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 命名导出:ai-mcp 实验矩阵 ② 的逗号表达式安全形态(返回 hooks 对象会触发 Ae is not a function)
|
||||
export const CompactionCn: any = async (_ctx: any) => (await ({} as any), undefined as any)
|
||||
|
||||
export default {
|
||||
id: "compaction-cn",
|
||||
async setup(ctx: any): Promise<void> {
|
||||
try {
|
||||
const session = ctx && ctx.session
|
||||
if (!session || typeof session.hook !== "function") {
|
||||
fileLog("session.hook 不可用(keys=" + JSON.stringify(Object.keys(session || {})) + "),未注册")
|
||||
return
|
||||
}
|
||||
|
||||
// ① compaction 钩子:system 段追加中文规则(原地 push ⇒ trigger 返回同一对象,必进 request)
|
||||
const registration = await session.hook("compaction", (g: any) => {
|
||||
try {
|
||||
if (!g) return
|
||||
const sys = g.system
|
||||
if (!Array.isArray(sys)) {
|
||||
fileLog("compaction payload 无 system 数组(type=" + typeof sys + ")keys=" + JSON.stringify(Object.keys(g)))
|
||||
return
|
||||
}
|
||||
if (sys.some((s: any) => systemText(s).indexOf(MARK) >= 0)) return
|
||||
sys.push({ type: "text", text: CN_RULE })
|
||||
fileLog(
|
||||
"已注入中文输出指令(system) sessionID=" +
|
||||
JSON.stringify(g.sessionID) +
|
||||
" system段=" +
|
||||
sys.length +
|
||||
" messages=" +
|
||||
(Array.isArray(g.messages) ? g.messages.length : "?"),
|
||||
)
|
||||
} catch (e: any) {
|
||||
fileLog("compaction 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
||||
}
|
||||
})
|
||||
fileLog("compaction 钩子注册完成 registration=" + JSON.stringify(registration && typeof registration === "object" ? Object.keys(registration) : String(registration)))
|
||||
|
||||
// ② http.request 钩子:请求出网前的最后关口
|
||||
// v4:压缩请求 → 模板之后追加语言指令(A/B 实测唯一稳定生效的位置)
|
||||
// v5:扩展到所有 JSON 聊天请求 → 非压缩请求在 messages 末尾注入常驻中文引导
|
||||
// 日志节流:同 sessionID 60s 内最多记一条常驻注入日志
|
||||
const langLogAt = new Map<string, number>()
|
||||
const hr = await session.hook("http.request", async (f: any) => {
|
||||
try {
|
||||
if (!f || !f.request || typeof f.request.clone !== "function") return
|
||||
const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "")
|
||||
if (ct && ct.indexOf("json") < 0) return
|
||||
let raw = ""
|
||||
try {
|
||||
raw = await f.request.clone().text()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (raw.length === 0) return
|
||||
const kindStr = typeof f.kind === "string" ? f.kind : ""
|
||||
const isCompaction =
|
||||
raw.indexOf(FP_HEAD) >= 0 && raw.indexOf(FP_TAIL) >= 0 && (kindStr === "" || kindStr === "compaction")
|
||||
let obj: any
|
||||
try {
|
||||
obj = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const msgs = obj && obj.messages
|
||||
if (!Array.isArray(msgs) || msgs.length === 0) return
|
||||
const lastTxt = msgText(msgs[msgs.length - 1])
|
||||
|
||||
// ── v5 常驻中文引导(非压缩请求):末尾不含标记才注入 ──────────
|
||||
if (!isCompaction) {
|
||||
if (lastTxt.indexOf(LANG_MARK) >= 0) return
|
||||
msgs.push({ role: "user", content: [{ type: "text", text: LANG_DIRECTIVE }] })
|
||||
const headers5 = new Headers(f.request.headers)
|
||||
headers5.delete("content-length")
|
||||
f.request = new Request(f.request.url, {
|
||||
method: f.request.method,
|
||||
headers: headers5,
|
||||
body: JSON.stringify(obj),
|
||||
})
|
||||
const sid = String(f.sessionID || (obj && obj.sessionID) || "?")
|
||||
const now = Date.now()
|
||||
if (!langLogAt.has(sid) || now - (langLogAt.get(sid) as number) > 60000) {
|
||||
langLogAt.set(sid, now)
|
||||
fileLog("常驻中文引导已注入 session=" + sid + " kind=" + (kindStr || "?") + " messages=" + msgs.length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── 以下为压缩请求的 v4 双保险逻辑(保持不变)────────────────
|
||||
if (lastTxt.indexOf(MARK) >= 0) return
|
||||
fileLog("body 顶层keys=" + JSON.stringify(Object.keys(obj)) + " temperature=" + JSON.stringify(obj.temperature) + " model=" + JSON.stringify(obj.model))
|
||||
// 诊断:逐条 message 结构(role / 长度 / 开头 60 字)——只记结构不落全文,
|
||||
// 全文含完整会话内容,落盘既占地方又有隐私问题,定位完成后不再需要。
|
||||
try {
|
||||
const brief = msgs
|
||||
.map((m: any, i: number) => i + ":" + String((m && m.role) || "?") + ":" + msgText(m).length + ":" + msgText(m).slice(0, 60).replace(/\s+/g, " "))
|
||||
.join(" || ")
|
||||
fileLog("结构: " + brief)
|
||||
} catch {}
|
||||
// ②-a 把语言规则写进压缩模板消息本身(紧跟 FP_TAIL),与模板同一条消息
|
||||
let inlined = false
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const t = msgText(msgs[i])
|
||||
if (t.indexOf(FP_HEAD) >= 0 && t.indexOf(FP_TAIL) >= 0) {
|
||||
if (t.indexOf(MARK) >= 0) break
|
||||
inlined = appendToMsg(msgs[i], CN_INLINE)
|
||||
break
|
||||
}
|
||||
}
|
||||
// ②-b 再在 messages 最末尾追加一条独立指令(A/B 实测唯一稳定生效的位置)
|
||||
msgs.push({ role: "user", content: [{ type: "text", text: CN_DIRECTIVE }] })
|
||||
const headers = new Headers(f.request.headers)
|
||||
headers.delete("content-length")
|
||||
f.request = new Request(f.request.url, { method: f.request.method, headers: headers, body: JSON.stringify(obj) })
|
||||
fileLog("已在压缩模板后追加中文指令 messages=" + msgs.length + " 模板内联=" + inlined + " 原body=" + raw.length + "字节")
|
||||
} catch (e: any) {
|
||||
fileLog("http.request 钩子异常(已吞掉,未改写请求): " + (e && e.message ? e.message : String(e)))
|
||||
}
|
||||
})
|
||||
fileLog("http.request 钩子注册完成 registration=" + JSON.stringify(hr && typeof hr === "object" ? Object.keys(hr) : String(hr)))
|
||||
|
||||
// ③ model.request 诊断:实测 payload 只有 sessionID/agent/model/kind/baseURL/headers
|
||||
const mr = await session.hook("model.request", (p: any) => {
|
||||
try {
|
||||
if (!p || p.kind !== "compaction") return
|
||||
fileLog(
|
||||
"model.request(compaction) keys=" +
|
||||
JSON.stringify(Object.keys(p)) +
|
||||
" system=" +
|
||||
(Array.isArray(p.system) ? p.system.length : typeof p.system) +
|
||||
" messages=" +
|
||||
(Array.isArray(p.messages) ? p.messages.length : typeof p.messages),
|
||||
)
|
||||
} catch (e: any) {
|
||||
fileLog("model.request 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
||||
}
|
||||
})
|
||||
fileLog("model.request 钩子注册完成 registration=" + JSON.stringify(mr && typeof mr === "object" ? Object.keys(mr) : String(mr)))
|
||||
} catch (e: any) {
|
||||
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
|
||||
}
|
||||
// setup 必须返回 void:返回 hooks 对象会触发 Ae is not a function 整批崩溃
|
||||
return
|
||||
},
|
||||
}
|
||||
@@ -1,964 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
mcpctl —— 命令行版的 MCP 配置对调开关(与 TUI 插件 / server 插件同一套逻辑)
|
||||
|
||||
做什么:
|
||||
status 查看当前模式与 MCP 开/关状态(只读,不改任何文件)
|
||||
on 把配置对调为「含 MCP」的原始版,OC 在跑则热重载(没跑就跳过)
|
||||
off 把配置对调为「不含 MCP」的安静版,OC 在跑则热重载(没跑就跳过)
|
||||
mode 查看或修改 mcpcfg.txt 模式键(自动 / 手动 / 关闭)
|
||||
|
||||
模式语义(与插件完全一致):
|
||||
自动 大模型可通过 server 插件切换;/mcpon /mcpoff 与本工具视为手动切换一次
|
||||
手动 仅允许 /mcpon /mcpoff 与本工具(用户意图),大模型的切换请求会被拒绝
|
||||
关闭 功能整体禁用,on/off 都会被拒绝
|
||||
|
||||
状态判定只看活文件(basename 为 opencode.json 的那份)里 mcp.servers 是否非空,
|
||||
与 mcpcfg.txt「含MCP的配置」/「安静配置」两键的行语义无关,因此对调之后身份漂移也不影响判断。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
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()
|
||||
|
||||
MODE_LABEL = {"auto": "自动", "manual": "手动", "off": "关闭"}
|
||||
LABEL_MODE = {
|
||||
"自动": "auto",
|
||||
"auto": "auto",
|
||||
"audo": "auto", # 用户手误拼写,保持与插件一致
|
||||
"手动": "manual",
|
||||
"manual": "manual",
|
||||
"hand": "manual",
|
||||
"on": "manual",
|
||||
"关闭": "off",
|
||||
"off": "off",
|
||||
"disable": "off",
|
||||
"disabled": "off",
|
||||
}
|
||||
|
||||
DEFAULT_DISTRO = os.environ.get("OC_SKILL_DISTRO", "Ubuntu-24.04")
|
||||
DEFAULT_PORT = int(os.environ.get("OC_PORT", "4096"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径工具
|
||||
def _basename(path: str) -> str:
|
||||
"""跨平台取文件名:同时认 / 与 \\,避免在 Windows 上解析 WSL 路径出错。"""
|
||||
return re.split(r"[\\/]", path.strip())[-1]
|
||||
|
||||
|
||||
def _home_config() -> Path:
|
||||
return Path.home() / ".config" / "opencode"
|
||||
|
||||
|
||||
def wsl_config_dir(distro: str) -> Path:
|
||||
if os.name == "nt":
|
||||
return Path(f"\\\\wsl.localhost\\{distro}\\root\\.config\\opencode")
|
||||
return Path("/root/.config/opencode")
|
||||
|
||||
|
||||
def resolve_cfg_file(target: str, distro: str) -> Path:
|
||||
if target == "wsl":
|
||||
return wsl_config_dir(distro) / "mcpcfg.txt"
|
||||
return _home_config() / "mcpcfg.txt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- WSL 安全访问
|
||||
# 1) wsl.exe 子进程绝不留管道:stdout/stderr 落临时文件。否则 wslrelay 等
|
||||
# 后代进程继承管道句柄,client 被超时 kill 后 communicate() 等 EOF 会
|
||||
# 永久阻塞(skill 目录“卡死必须 ctrl+C”的根因)。
|
||||
# 2) wsl.exe 重活之前先查 --running:VM 没跑绝不触发无提示冷启动。
|
||||
def _wsl_safe_run(argv: list, timeout: float = 10.0) -> tuple[int, bytes]:
|
||||
if os.name != "nt":
|
||||
return -1, b""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryFile() as fo, tempfile.TemporaryFile() as fe:
|
||||
try:
|
||||
cp = subprocess.run(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=fo,
|
||||
stderr=fe,
|
||||
timeout=timeout,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
fo.seek(0)
|
||||
fe.seek(0)
|
||||
return cp.returncode, fo.read() + fe.read()
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, b""
|
||||
except Exception as exc:
|
||||
return -2, str(exc).encode("utf-8", "replace")
|
||||
|
||||
|
||||
def _wsl_running(distro: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return True
|
||||
code, out = _wsl_safe_run(["wsl.exe", "-l", "-q", "--running"], timeout=8)
|
||||
if code != 0 or not out:
|
||||
return False
|
||||
# wsl.exe 列表输出是 UTF-16LE(常无 BOM)
|
||||
text = (
|
||||
out.decode("utf-16le", "ignore")
|
||||
if out.count(b"\x00") > len(out) // 4
|
||||
else out.decode("utf-8", "ignore")
|
||||
)
|
||||
return distro in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 读操作防卡死
|
||||
# WSL UNC 路径(\\wsl.localhost\...)在 VM 半死 / 9p 无响应时,Path.exists() /
|
||||
# read_text 会在系统调用层无限挂起,且 Ctrl+C 打不断(Python 信号要等阻塞的
|
||||
# 系统调用返回才处理)—— 表现为「CLI 进入 mcp 查看直接卡死,只能强杀进程」。
|
||||
# 只读链路统一放进 daemon 工作线程限时等待:超时打印原因并返回菜单;
|
||||
# 主线程等的是 queue.get(timeout)(Python 层等待),Ctrl+C 随时能打断。
|
||||
def _guard(fn, timeout: float):
|
||||
"""在工作线程里跑 fn,主线程最多等 timeout 秒。
|
||||
返回 (True, 结果) 或 (False, 错误描述);错误描述为 'TIMEOUT' 表示超时。"""
|
||||
q: "queue.Queue" = queue.Queue(maxsize=1)
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
q.put((True, fn()))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
q.put((False, f"{type(exc).__name__}: {exc}"))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
try:
|
||||
return q.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return False, "TIMEOUT"
|
||||
|
||||
|
||||
def _read_timeout(target: str) -> float:
|
||||
"""只读链路超时:本机文件秒回给 8s;WSL UNC 冷路径首访给 20s。"""
|
||||
return 20.0 if target == "wsl" else 8.0
|
||||
|
||||
|
||||
def _print_read_fail(what, res, tmo: float) -> int:
|
||||
"""_guard 读失败的统一出口(超时 / 异常都回菜单,不挂死)。"""
|
||||
if res == "TIMEOUT":
|
||||
print(f"读取超时(>{tmo:g}s):{what}")
|
||||
print(" WSL UNC 路径无响应时不再无限挂起;请检查 wsl.exe 状态后重试。")
|
||||
else:
|
||||
print(f"读取失败:{res}")
|
||||
return 2
|
||||
|
||||
|
||||
def _run_with_beats(fn, interval: float = 5.0):
|
||||
"""长操作期间每 interval 秒打一个进度点,避免用户把「等待」当成「卡死」。"""
|
||||
stop = threading.Event()
|
||||
|
||||
def _beat() -> None:
|
||||
while not stop.wait(interval):
|
||||
print(" .", end="", flush=True)
|
||||
|
||||
threading.Thread(target=_beat, daemon=True).start()
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
stop.set()
|
||||
print(flush=True)
|
||||
|
||||
|
||||
def fs_view(p: str, target: str, distro: str) -> str:
|
||||
"""mcpcfg 内部路径 -> 当前系统可访问的视图。
|
||||
Windows 操作 WSL 目标时,/root/... 必须换成 \\\\wsl.localhost\\<distro>\\...,
|
||||
否则 Path("/root/...") 会被解析成 C:\\root → 误报「两键路径不存在」
|
||||
(用户看到的“键值不存在 / mcpcfg.txt不存在”就是这个假错)。"""
|
||||
if target == "wsl" and os.name == "nt" and p.startswith("/"):
|
||||
return "\\\\wsl.localhost\\%s%s" % (distro, p.replace("/", "\\"))
|
||||
return p
|
||||
|
||||
|
||||
def wsl_gate(args, need_write: bool = False) -> str | None:
|
||||
"""WSL 目标统一门(返回 None=可继续;返回字符串=错误信息):
|
||||
- 读操作(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
|
||||
verb = "读配置" if not need_write else "写配置"
|
||||
print(f"WSL({args.distro})未运行,正在唤醒以便{verb}"
|
||||
f"(可能需要十几秒,期间每 5 秒打一个点)...", flush=True)
|
||||
_run_with_beats(
|
||||
lambda: _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})唤醒失败,无法{verb}。"
|
||||
f"请手动执行 wsl.exe -d {args.distro} 启动后重试。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- mcpcfg 解析
|
||||
# 与 tui.js / ai-mcp.ts 的 readMcpCfg 保持一致:
|
||||
# 优先按键值解析(模式: / 含MCP的配置: / 安静配置:,认中英文冒号),
|
||||
# 解析不到再回退旧「裸三行」(第1行模式、第2行含MCP、第3行安静)。
|
||||
def read_cfg(path: Path, target: str = "local", distro: str = DEFAULT_DISTRO) -> dict:
|
||||
if not path.exists():
|
||||
return {
|
||||
"mode": "off",
|
||||
"error": f"缺少配置文件:{path}(配置缺失时功能视为关闭)",
|
||||
}
|
||||
try:
|
||||
raw_text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as exc:
|
||||
return {"mode": "off", "error": f"读取 {path} 失败:{exc}"}
|
||||
|
||||
lines = raw_text.splitlines()
|
||||
|
||||
kv: dict[str, str] = {}
|
||||
for ln in lines:
|
||||
line = ln.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
continue
|
||||
m = re.match(r"^([^::]+)[::]\s*(.*)$", line)
|
||||
if m:
|
||||
kv[m.group(1).strip().lower()] = m.group(2).strip()
|
||||
|
||||
mode_val = kv.get("模式", kv.get("mode", kv.get("第1行")))
|
||||
on_val = kv.get("含mcp的配置", kv.get("含mcp配置", kv.get("含mcp", kv.get("on", kv.get("原始配置", kv.get("第2行"))))))
|
||||
quiet_val = kv.get("安静配置", kv.get("安静", kv.get("quiet", kv.get("备份", kv.get("backup", kv.get("第3行"))))))
|
||||
|
||||
is_legacy = mode_val is None and on_val is None and quiet_val is None
|
||||
if not is_legacy:
|
||||
mode = LABEL_MODE.get((mode_val or "自动").strip().lower(), "auto")
|
||||
on_path = (on_val or "").strip()
|
||||
quiet_path = (quiet_val or "").strip()
|
||||
else:
|
||||
bare = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("//")]
|
||||
mode = LABEL_MODE.get((bare[0] if bare else "").strip().lower(), "auto" if not bare or bare[0] == "" else "auto")
|
||||
on_path = (bare[1] if len(bare) > 1 else "").strip()
|
||||
quiet_path = (bare[2] if len(bare) > 2 else "").strip()
|
||||
|
||||
# 先按原文识别身份,再统一转成“可访问视图”(Windows→WSL 换 UNC);
|
||||
# raw 保留在结果里用于展示与报错(与用户在 txt 里看到的一致)
|
||||
on_raw, quiet_raw = on_path, quiet_path
|
||||
active_raw = shadow_raw = None
|
||||
if _basename(on_path) == "opencode.json":
|
||||
active_raw, shadow_raw = on_path, quiet_path
|
||||
elif _basename(quiet_path) == "opencode.json":
|
||||
active_raw, shadow_raw = quiet_path, on_path
|
||||
|
||||
on_path = fs_view(on_raw, target, distro)
|
||||
quiet_path = fs_view(quiet_raw, target, distro)
|
||||
active = fs_view(active_raw, target, distro) if active_raw else None
|
||||
shadow = fs_view(shadow_raw, target, distro) if shadow_raw else None
|
||||
|
||||
result = {
|
||||
"mode": mode,
|
||||
"on_path": on_path,
|
||||
"quiet_path": quiet_path,
|
||||
"active": active,
|
||||
"shadow": shadow,
|
||||
"on_raw": on_raw,
|
||||
"quiet_raw": quiet_raw,
|
||||
"active_raw": active_raw,
|
||||
"shadow_raw": shadow_raw,
|
||||
"legacy": is_legacy,
|
||||
"error": None,
|
||||
}
|
||||
if mode != "off" and not active:
|
||||
result["error"] = "mcpcfg.txt 两行路径都不是 opencode.json(「含MCP的配置」或「安静配置」需指向 opencode.json)"
|
||||
elif mode != "off" and (not Path(on_path).exists() or not Path(quiet_path).exists()):
|
||||
gone = [
|
||||
raw
|
||||
for raw, view in ((on_raw, on_path), (quiet_raw, quiet_path))
|
||||
if not Path(view).exists()
|
||||
]
|
||||
result["error"] = (
|
||||
"mcpcfg.txt 指向的配置文件不存在: "
|
||||
+ "、".join(gone)
|
||||
+ "(请检查「含MCP的配置」/「安静配置」两键的路径)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def write_cfg(path: Path, mode: str, on_path: str, quiet_path: str) -> None:
|
||||
"""按键值格式写回(模式/含MCP的配置/安静配置),带注释头。"""
|
||||
mode_cn = {"auto": "自动", "manual": "手动", "off": "关闭"}.get(mode, "自动")
|
||||
body = "\n".join([
|
||||
"# MCP 开关配置:改完保存,重启 OC 生效(第1个键=模式)",
|
||||
"# 模式可填:自动(大模型可切换) / 手动(仅 /mcpon /mcpoff) / 关闭(禁用本功能)",
|
||||
f"模式: {mode_cn}",
|
||||
f"含MCP的配置: {on_path}",
|
||||
f"安静配置: {quiet_path}",
|
||||
"",
|
||||
])
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def set_mode_in_file(path: Path, mode: str) -> None:
|
||||
"""只改模式键:键值文件按键替换;旧三行文件替换第1行(插件下次读会自动迁移)。"""
|
||||
raw = path.read_text(encoding="utf-8", errors="replace")
|
||||
lines = raw.splitlines()
|
||||
mode_cn = {"auto": "自动", "manual": "手动", "off": "关闭"}.get(mode, "自动")
|
||||
|
||||
kv_pattern = re.compile(r"^(模式|mode)\s*[::]\s*.*$", re.IGNORECASE)
|
||||
hit = False
|
||||
for i, ln in enumerate(lines):
|
||||
if kv_pattern.match(ln.strip()):
|
||||
prefix = ":" if "模式" in ln.split(":")[0] or ":" in ln else ":"
|
||||
lines[i] = f"模式{prefix} {mode_cn}"
|
||||
hit = True
|
||||
break
|
||||
if not hit:
|
||||
# 旧三行(或键缺失):第1行位放模式;若本来是键值缺模式键,插到最前面
|
||||
if any(re.match(r"^([^::]+)[::]", l.strip()) for l in lines if l.strip() and not l.strip().startswith("#")):
|
||||
lines.insert(0, f"模式: {mode_cn}")
|
||||
else:
|
||||
while len(lines) < 3:
|
||||
lines.append("")
|
||||
lines[0] = mode_cn
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def flag_path(cfg_dir_target: str, distro: str) -> Path:
|
||||
"""显式开启标记 mcp.on.flag 的落点(与插件共用;wsl 目标落在 WSL 目录)。"""
|
||||
base = wsl_config_dir(distro) if cfg_dir_target == "wsl" else _home_config()
|
||||
return base / "mcp.on.flag"
|
||||
|
||||
|
||||
def set_on_flag(cfg_dir_target: str, distro: str, on: bool) -> None:
|
||||
try:
|
||||
p = flag_path(cfg_dir_target, distro)
|
||||
if on:
|
||||
p.write_text(str(os.getpid()), encoding="utf-8")
|
||||
elif p.exists():
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def active_has_mcp(active: str | None) -> bool:
|
||||
try:
|
||||
if not active:
|
||||
return False
|
||||
data = json.loads(Path(active).read_text(encoding="utf-8"))
|
||||
servers = (data.get("mcp") or {}).get("servers") or {}
|
||||
return bool(servers)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def swap_files(a: str, b: str) -> None:
|
||||
pa, pb = Path(a), Path(b)
|
||||
ca, cb = pa.read_bytes(), pb.read_bytes()
|
||||
pa.write_bytes(cb)
|
||||
pb.write_bytes(ca)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reload
|
||||
def _read_password(cfg_dir: Path) -> str:
|
||||
"""口令读取链(service.json → 环境变量 → opencode.json,与 ocskill 旧版一致):
|
||||
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")
|
||||
return str(pwd) if pwd else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _wsl_ip(distro: str) -> str:
|
||||
"""取 WSL IP;VM 没跑直接返回空(绝不无提示冷启动),
|
||||
且走 _wsl_safe_run(零管道)——原 capture_output 版在 VM 被清理后
|
||||
会因 wslrelay 继承管道句柄导致 kill 后 communicate 永久阻塞。"""
|
||||
if os.name != "nt" or not _wsl_running(distro):
|
||||
return ""
|
||||
code, out = _wsl_safe_run(
|
||||
["wsl.exe", "-d", distro, "--", "bash", "-lc", "hostname -I"], timeout=15
|
||||
)
|
||||
if code != 0:
|
||||
return ""
|
||||
for tok in out.decode("utf-8", "ignore").split():
|
||||
if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", tok):
|
||||
return tok
|
||||
return ""
|
||||
|
||||
|
||||
# 关键:本机/WSL 的 OC 绝不能走系统代理。
|
||||
# 环境里若有 HTTP_PROXY/HTTPS_PROXY,urlopen 会把 localhost 请求丢给代理,
|
||||
# 结果是代理回 502 —— 看起来像 OC 挂了,其实是被劫走了。
|
||||
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
if os.environ.get("MCPCTL_USE_PROXY"):
|
||||
_NO_PROXY_OPENER = urllib.request.build_opener()
|
||||
|
||||
|
||||
def _post(
|
||||
url: str,
|
||||
password: str,
|
||||
timeout: float,
|
||||
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:
|
||||
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:
|
||||
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 系进程的 PID,netstat -ano 找这些 PID 的
|
||||
LISTENING TCP 端口。OC 每次启动端口随机(实测 49374),写死 4096 必 10061;
|
||||
--port 4097 启动时默认端口探活扑空,全靠这里补中。
|
||||
进程名前缀匹配:既要普通 `opencode.exe`,也要 service 形态
|
||||
`opencode-service-<pid>-<hash>.exe`(tasklist 过滤 eq opencode.exe 会全漏)。
|
||||
任何一步失败都返回 [](回退默认端口),绝不抛异常。与 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",...
|
||||
# 前缀匹配(非等值):opencode*.exe 覆盖 opencode.exe / opencode-service-*.exe
|
||||
if len(cols) >= 2 and cols[0].lower().startswith("opencode") 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:
|
||||
# 2026-09-25 用户反馈:OC 没启动时连接被拒绝,原文「连不上(WinError
|
||||
# 10061…)」会被读成 API 报错;按真实状态说(没监听 = OC 未启动)。
|
||||
text = body or ""
|
||||
low = text.lower()
|
||||
if "10061" in text or "refused" in low or "积极拒绝" in text:
|
||||
last = f"{host} 无进程监听(OC 未启动)"
|
||||
elif "10060" in text or "timed out" in low or "超时" in text:
|
||||
last = f"{host} 连接超时(OC 未启动或不可达)"
|
||||
else:
|
||||
last = f"{host} 连不上({text[:80]})"
|
||||
return "", directory, last or "没有找到运行中的 OC(4096 未监听,端口发现也未命中)"
|
||||
|
||||
|
||||
def do_reload(target: str, args, host: str | None = None,
|
||||
password: str | None = None) -> tuple[bool, str]:
|
||||
if password is None:
|
||||
password = args.password or _read_password(
|
||||
wsl_config_dir(args.distro) if target == "wsl" else _home_config()
|
||||
)
|
||||
if not host:
|
||||
# 端口发现 + 探活:先找到真正在跑的 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):
|
||||
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, host: str | None = None,
|
||||
password: str | None = None) -> tuple[bool, str]:
|
||||
"""reload 之前的兜底:把活配置里的 mcp 直接 PATCH 进运行中的 OC。
|
||||
与 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)
|
||||
if cfg.get("error") or not cfg.get("active"):
|
||||
return False, cfg.get("error") or "未识别活配置"
|
||||
try:
|
||||
data = json.loads(Path(cfg["active"]).read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
return False, f"读活配置失败:{exc}"
|
||||
mcp = data.get("mcp")
|
||||
if not isinstance(mcp, dict) or not ((mcp.get("servers") or {})):
|
||||
return False, "活配置里没有 mcp.servers,无可推送内容"
|
||||
|
||||
if password is None:
|
||||
password = args.password or _read_password(
|
||||
wsl_config_dir(args.distro) if args.target == "wsl" else _home_config()
|
||||
)
|
||||
# 1) 端口发现 + 探活:GET /api/config 200 = 命中运行中的 OC(4096 常无监听)
|
||||
# 调用方(_switch)已探活时直接复用 host,不重复探测
|
||||
if host:
|
||||
directory = _cfg_directory(args.target, args.distro)
|
||||
else:
|
||||
host, directory, why = _find_live_host(args.target, args, password)
|
||||
if not host:
|
||||
return False, why
|
||||
encoded = urllib.parse.quote(directory, safe=":/")
|
||||
|
||||
# 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 + "/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]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 子命令
|
||||
def _stamp() -> str:
|
||||
return datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def print_state(cfg: dict, has: bool | None = None) -> bool:
|
||||
# has 由调用方经 _guard 预读(UNC 半死时活配置读取会挂,不能在这里裸读)
|
||||
if has is None:
|
||||
has = active_has_mcp(cfg.get("active"))
|
||||
print(f"模式:{MODE_LABEL.get(cfg['mode'], cfg['mode'])}(mcpcfg.txt「模式」键)")
|
||||
print(f"MCP :{'已开启(配置含 mcp.servers)' if has else '已关闭(安静,无 mcp 配置)'}")
|
||||
# 展示用原文(用户在 mcpcfg.txt 里看到的路径),内部访问已走视图翻译
|
||||
shown = cfg.get("active_raw") or cfg.get("active") or "(未识别)"
|
||||
print(f"活配置:{shown}")
|
||||
if cfg.get("error"):
|
||||
print(f"警告:{cfg['error']}")
|
||||
return has
|
||||
|
||||
|
||||
def cmd_status(args) -> int:
|
||||
err = wsl_gate(args, need_write=False)
|
||||
if err:
|
||||
print(err)
|
||||
return 2
|
||||
path = resolve_cfg_file(args.target, args.distro)
|
||||
print(f"配置文件:{path}")
|
||||
tmo = _read_timeout(args.target)
|
||||
# 读链全线走 _guard:mcpcfg / 活配置任意一处 UNC 无响应都限时返回,不卡死
|
||||
ok, cfg = _guard(lambda: read_cfg(path, args.target, args.distro), tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(path, cfg, tmo)
|
||||
ok, has = _guard(lambda: active_has_mcp(cfg.get("active")), tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(cfg.get("active") or "活配置", has, tmo)
|
||||
print_state(cfg, has)
|
||||
return 0 if not cfg.get("error") else 2
|
||||
|
||||
|
||||
def _live_oc(target: str, args) -> tuple[str, str, str | None]:
|
||||
"""热重载前置探活(2026-09-25 用户反馈新增)。
|
||||
|
||||
返回 (host, password, skip_reason):
|
||||
skip_reason 为 None → host 是运行中的 OC,可直接热重载(复用,不二探);
|
||||
skip_reason 非 None → 没有可重载的 OC,调用方打印原因后按成功返回 ——
|
||||
WSL 没启动 / OC 没在跑时,配置文件已经对调好,下次冷启动 OC 自己就会
|
||||
加载,绝不能报「热重载失败」(如 WinError 10061 连接拒绝)。
|
||||
"""
|
||||
if target == "wsl":
|
||||
try:
|
||||
wsl_on = _wsl_running(args.distro)
|
||||
except Exception:
|
||||
wsl_on = False
|
||||
if not wsl_on:
|
||||
return "", "", "WSL 未启动(OC 自然也没在运行)"
|
||||
password = args.password or _read_password(
|
||||
wsl_config_dir(args.distro) if target == "wsl" else _home_config()
|
||||
)
|
||||
host, _dir, why = _find_live_host(target, args, password)
|
||||
if not host:
|
||||
return "", password, (why or "没有找到运行中的 OC")
|
||||
return host, password, None
|
||||
|
||||
|
||||
def _switch(args, want_on: bool) -> int:
|
||||
# 写操作:WSL 没跑先唤醒(gate 在任何 UNC .exists() 之前)
|
||||
err = wsl_gate(args, need_write=True)
|
||||
if err:
|
||||
print(err)
|
||||
return 2
|
||||
path = resolve_cfg_file(args.target, args.distro)
|
||||
print(f"配置文件:{path}")
|
||||
tmo = _read_timeout(args.target)
|
||||
ok, cfg = _guard(lambda: read_cfg(path, args.target, args.distro), tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(path, cfg, tmo)
|
||||
if cfg.get("error"):
|
||||
print(f"失败:{cfg['error']}")
|
||||
return 2
|
||||
|
||||
action = "开启" if want_on else "关闭"
|
||||
if cfg["mode"] == "off" and not args.ignore_mode:
|
||||
print("拒绝:MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。")
|
||||
print("如需使用,请把模式改成「自动」或「手动」。")
|
||||
return 3
|
||||
|
||||
ok, has = _guard(lambda: active_has_mcp(cfg["active"]), tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(cfg.get("active") or "活配置", has, tmo)
|
||||
if has == want_on:
|
||||
print(f"无需切换:MCP 已是{'开启' if want_on else '关闭(安静)'}状态。")
|
||||
print_state(cfg, has)
|
||||
return 0
|
||||
|
||||
swap_files(cfg["on_path"], cfg["quiet_path"])
|
||||
after = active_has_mcp(cfg["active"])
|
||||
if after != want_on:
|
||||
print("失败:配置对调后状态校验不符,请检查 mcpcfg.txt「含MCP的配置」/「安静配置」路径。")
|
||||
return 4
|
||||
|
||||
# 显式意图落标记:开写 mcp.on.flag、关删标记(与 tui.js / ai-mcp 共用,
|
||||
# 避免自动模式的“启动归位”把刚开启的状态又换回去)
|
||||
set_on_flag(args.target, args.distro, want_on)
|
||||
|
||||
print(f"配置已对调,MCP 现在是「{'开启' if after else '关闭'}」。")
|
||||
|
||||
if args.no_reload:
|
||||
print("已按 --no-reload 跳过热重载,需重启 OC 生效。")
|
||||
return 0
|
||||
|
||||
# 热重载前置探活(2026-09-25 用户反馈):WSL 没启动 / OC 没在跑就不重载、
|
||||
# 也不算失败 —— 配置文件已对调好,下次冷启动 OC 自己就会加载
|
||||
host, pwd, skip = _live_oc(args.target, args)
|
||||
if skip:
|
||||
print(f"无需热重载:{skip}。")
|
||||
print(" 配置已对调完成,等 OC 下次启动时会自动加载新配置。")
|
||||
return 0
|
||||
|
||||
# 兜底推送(仅开启方向):reload 之前先把 mcp 配置送进运行中的 OC,
|
||||
# 推送成功 = 双保险;推送失败不阻断,走 reload 主通道。
|
||||
# 推送失败不在这里就报负面消息 —— 它只是额外的双保险通道,
|
||||
# 是否真的生效由 reload 结果决定(避免「MCP 已可用却提示兜底」误导用户)。
|
||||
pushed = None
|
||||
pmsg = ""
|
||||
if want_on:
|
||||
pushed, pmsg = push_mcp_config(args, host=host, password=pwd)
|
||||
if pushed:
|
||||
print(f"mcp 配置已先推送进 OC:{pmsg}")
|
||||
|
||||
ok, msg = do_reload(args.target, args, host=host, password=pwd)
|
||||
if ok:
|
||||
if pushed:
|
||||
print(f"{msg},mcp 配置已先送入 + 热重载(双保险)。")
|
||||
else:
|
||||
print(f"{msg},MCP 配置已生效。")
|
||||
return 0
|
||||
print(f"热重载失败:{msg}")
|
||||
if pushed:
|
||||
print("但 mcp 配置已提前推送进 OC,运行中的实例已收到新配置;重启 OpenCode 可完全同步。")
|
||||
return 0
|
||||
if pmsg:
|
||||
print(f"(双保险推送也未成功:{pmsg})")
|
||||
print("配置文件已经对调完成,重启 OpenCode 也能生效。")
|
||||
return 5
|
||||
|
||||
|
||||
def cmd_on(args) -> int:
|
||||
return _switch(args, True)
|
||||
|
||||
|
||||
def cmd_off(args) -> int:
|
||||
return _switch(args, False)
|
||||
|
||||
|
||||
def cmd_mode(args) -> int:
|
||||
# 查看=读门(VM 没跑秒回提示);设置=写门(唤醒)
|
||||
err = wsl_gate(args, need_write=bool(args.value))
|
||||
if err:
|
||||
print(err)
|
||||
return 2
|
||||
path = resolve_cfg_file(args.target, args.distro)
|
||||
tmo = _read_timeout(args.target)
|
||||
ok, ex = _guard(lambda: path.exists(), tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(path, ex, tmo)
|
||||
if not ex:
|
||||
print(f"配置文件不存在:{path}")
|
||||
return 2
|
||||
|
||||
if not args.value:
|
||||
ok, cfg = _guard(lambda: read_cfg(path, args.target, args.distro), tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(path, cfg, tmo)
|
||||
cur = MODE_LABEL.get(cfg.get("mode", "auto"), cfg.get("mode", "auto"))
|
||||
print(f"当前模式:{cur}")
|
||||
print("可选值:自动 / 手动 / 关闭(也支持 auto / manual / off)")
|
||||
return 0
|
||||
|
||||
key = args.value.strip().lower()
|
||||
if key not in LABEL_MODE:
|
||||
print(f"无法识别的模式:{args.value}(可选:自动 / 手动 / 关闭 / auto / manual / off)")
|
||||
return 2
|
||||
|
||||
set_mode_in_file(path, LABEL_MODE[key])
|
||||
print(f"已把模式设为:{MODE_LABEL[LABEL_MODE[key]]}({path})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_backup(args) -> int:
|
||||
err = wsl_gate(args, need_write=False)
|
||||
if err:
|
||||
print(err)
|
||||
return 2
|
||||
path = resolve_cfg_file(args.target, args.distro)
|
||||
tmo = _read_timeout(args.target)
|
||||
ok, data = _guard(lambda: path.read_bytes() if path.exists() else None, tmo)
|
||||
if not ok:
|
||||
return _print_read_fail(path, data, tmo)
|
||||
if data is None:
|
||||
print(f"配置文件不存在:{path}")
|
||||
return 2
|
||||
bak = path.with_name(path.name + f".bak-{_stamp()}")
|
||||
bak.write_bytes(data)
|
||||
print(f"已备份:{bak}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 参数顺序自愈
|
||||
# 共享 flag 定义在主解析器(子命令之前),`mcpctl status --target wsl` 这种
|
||||
# 写法会被 argparse 判为 unrecognized。这里在 parse 之前把共享 flag 从任意
|
||||
# 位置提到最前,让用户不必记参数顺序(与 oc.py 菜单直传兼容)。
|
||||
_SHARED_VALUE_FLAGS = {"--target", "--distro", "--port", "--timeout", "--password"}
|
||||
_SHARED_BOOL_FLAGS = {"--no-reload", "--ignore-mode"}
|
||||
|
||||
|
||||
def _hoist_shared(argv: list[str]) -> list[str]:
|
||||
head: list[str] = []
|
||||
rest: list[str] = []
|
||||
i = 0
|
||||
n = len(argv)
|
||||
while i < n:
|
||||
tok = argv[i]
|
||||
key = tok.split("=", 1)[0]
|
||||
if key in _SHARED_VALUE_FLAGS:
|
||||
if "=" in tok:
|
||||
head.append(tok)
|
||||
i += 1
|
||||
else:
|
||||
head.append(tok)
|
||||
if i + 1 < n:
|
||||
head.append(argv[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
elif key in _SHARED_BOOL_FLAGS:
|
||||
head.append(tok)
|
||||
i += 1
|
||||
else:
|
||||
rest.append(tok)
|
||||
i += 1
|
||||
return head + rest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="mcpctl",
|
||||
description="MCP 配置对调开关(status / on / off / mode),与插件共用同一份 mcpcfg.txt",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python mcpctl.py status\n"
|
||||
" python mcpctl.py on\n"
|
||||
" python mcpctl.py off --no-reload\n"
|
||||
" python mcpctl.py mode 手动\n"
|
||||
" python mcpctl.py status --target wsl\n"
|
||||
),
|
||||
)
|
||||
p.add_argument("--target", choices=["local", "wsl"], default="local", help="操作本机还是 WSL 的配置(默认 local)")
|
||||
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 口令(默认按 service.json → 环境变量 → opencode.json 顺序自动读)")
|
||||
p.add_argument("--no-reload", action="store_true", help="只对调文件,不调用热重载")
|
||||
p.add_argument("--ignore-mode", action="store_true", help="忽略「模式: 关闭」限制强制切换(慎用)")
|
||||
|
||||
sub = p.add_subparsers(dest="cmd")
|
||||
|
||||
sp = sub.add_parser("status", help="查看当前模式与 MCP 开关状态")
|
||||
sp.set_defaults(func=cmd_status)
|
||||
|
||||
sp = sub.add_parser("on", help="对调为含 MCP 的配置并热重载")
|
||||
sp.set_defaults(func=cmd_on)
|
||||
|
||||
sp = sub.add_parser("off", help="对调为安静配置(删 MCP)并热重载")
|
||||
sp.set_defaults(func=cmd_off)
|
||||
|
||||
sp = sub.add_parser("mode", help="查看或修改 mcpcfg.txt「模式」键(自动/手动/关闭)")
|
||||
sp.add_argument("value", nargs="?", help="要设置的模式:自动 / 手动 / 关闭(不传则只查看)")
|
||||
sp.set_defaults(func=cmd_mode)
|
||||
|
||||
sp = sub.add_parser("backup", help="备份当前 mcpcfg.txt")
|
||||
sp.set_defaults(func=cmd_backup)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
if argv is None:
|
||||
argv = sys.argv[1:]
|
||||
argv = _hoist_shared(list(argv))
|
||||
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())
|
||||
@@ -1,21 +0,0 @@
|
||||
@echo off
|
||||
rem OC toolbox single entry: menu mode when no args, passthrough otherwise.
|
||||
rem UTF-8 console codepage so the Python menu (Chinese) prints correctly.
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "PY="
|
||||
where py >nul 2>nul && set "PY=py -3"
|
||||
if not defined PY where python >nul 2>nul && set "PY=python"
|
||||
if not defined PY where python3 >nul 2>nul && set "PY=python3"
|
||||
if not defined PY (
|
||||
echo [ERROR] Python not found in PATH. Install Python 3.10+ first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%PY% oc.py %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
if not "%~1"=="" pause
|
||||
exit /b %RC%
|
||||
@@ -1,681 +0,0 @@
|
||||
#!/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"))
|
||||
if brief.get("has_md") is False:
|
||||
# 空壳 / 断链:先给软链接诊断(找出真实文件所在位置),再让用户决定
|
||||
print(" ⚠ 该目录没有 SKILL.md(空壳或链接断了),"
|
||||
"推送时会自动切换到真实位置。诊断:")
|
||||
for line in brief.get("diag") or []:
|
||||
print(" %s" % line)
|
||||
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
|
||||
# 源是软链接 / 空壳 -> 自动切到真身再推(2026-09-25 用户要求:直接切,
|
||||
# 不再打印诊断让用户手动换来源)
|
||||
real = ocskill.real_src(src)
|
||||
if real and os.path.normcase(os.path.abspath(real)) != \
|
||||
os.path.normcase(os.path.abspath(src)):
|
||||
print(" 源是软链接或空壳,已自动切换到真实位置推送:")
|
||||
print(" 原:%s" % src)
|
||||
print(" 新:%s" % real)
|
||||
src = real
|
||||
# 技能选定后才选目标终端(列表已按所选来源打出)
|
||||
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(" 软链接诊断(真实文件位置):")
|
||||
for line in probe.get("diag") or []:
|
||||
print(" %s" % line)
|
||||
print(" (提示:回列表换一个,或直接贴完整 skill 的路径。)")
|
||||
else:
|
||||
print(" 校验未通过:%s" % err)
|
||||
for line in probe.get("diag") or []:
|
||||
print(" %s" % line)
|
||||
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 数量)")
|
||||
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()
|
||||
try:
|
||||
ocskill.load_registry() # 启动时把 skill.json 读进内存(空壳真身查询零扫盘)
|
||||
except Exception: # noqa: BLE001 —— 注册表读失败绝不挡住菜单
|
||||
pass
|
||||
if len(sys.argv) > 1:
|
||||
return dispatch(sys.argv[1:])
|
||||
menu_top()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
-1911
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user