skill软链接诊断层落地并打通WSL二层:缺SKILL.md时解symlink/junction真身(剥\??\前缀+POSIX换算UNC)、list与菜单列表⚠缺SKILL.md标注、二次确认与推送报错附带诊断;修复wsl.exe二次解析提前展开$var致脚本零输出改base64传输、目标读不出时realpath误报断链改标注交二层、VM未运行给跳过提示、find_skill_md吞OSError防炸穿、正常目录误报仍未找到;ocskill删OC API链全量直读skill目录;15项单测全绿
This commit is contained in:
@@ -46,7 +46,7 @@ python oc.py list # 自动识别归属
|
||||
### 1. `ocskill.py` — skill 互通
|
||||
|
||||
```bash
|
||||
# 列出 WSL OC 的 skill(API 连不上会自动退回目录扫描)
|
||||
# 列出 WSL OC 的 skill(直读 skill 目录,不走 API)
|
||||
python ocskill.py list
|
||||
|
||||
# 推送:压缩包(自动解压)
|
||||
@@ -70,7 +70,7 @@ python ocskill.py push .\my-skill --force
|
||||
# 反向:把 WSL 里的 skill 拷回本机
|
||||
python ocskill.py pull java-expert --out .\skills-backup
|
||||
|
||||
# 看两端目录情况(路径 + skill 数量 + 名字摘要)与本机/WSL 分开的 API 连接状态
|
||||
# 看两端目录情况(路径 + 存在性 + skill 数量 + 名字摘要;不探测 API)
|
||||
python ocskill.py dirs
|
||||
```
|
||||
|
||||
@@ -92,9 +92,10 @@ python ocskill.py dirs
|
||||
| 参数 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `--distro` | `Ubuntu-24.04` | WSL 发行版名 |
|
||||
| `--url` | 自动探测 | 显式指定 OC 地址,如 `http://localhost:4096` |
|
||||
| `--port` | `4096` | OC 端口(等价于环境变量 `OC_PORT`) |
|
||||
| `--password` | 自动读 | OC 口令,读取链 `service.json` → 环境变量 `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD` → `opencode.json server.password`;鉴权统一 `Basic base64("opencode:<口令>")`(用户名固定 `opencode`)。端口 4096 没监听时会 `tasklist`+`netstat` 自动发现 OC 实际随机端口并探活 |
|
||||
|
||||
> 2026-09-24 起 `ocskill.py` 不再探测 OC API(`--url/--port/--timeout/--password`
|
||||
> 已移除):读 skill 一律直读目录,连上 WSL 就查目录,本机的就查本机 OC 目录。
|
||||
> `mcpctl.py`(配置对调 / 热重载)仍走 API,其 `--port/--password` 等参数不受影响。
|
||||
|
||||
### 2. `mcpctl.py` — MCP 配置对调开关
|
||||
|
||||
@@ -166,24 +167,46 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载
|
||||
| 唤醒 WSL 的十几秒里屏幕无输出,看着像卡死 | 唤醒统一包 `_run_with_beats()`:每 5 秒打一个 `.`,配合「正在唤醒(可能需要十几秒,期间每 5 秒打一个点)」提示,等待过程可见 |
|
||||
| 菜单被子命令 `raise SystemExit` 杀死 | `oc.py` 的 `run_safe()` 护栏:`KeyboardInterrupt` → 已取消回菜单;`SystemExit` → 报告信息回菜单;异常 → 打印错误回菜单,菜单本体永不退出 |
|
||||
|
||||
`list` / `dirs` 连接 OC 前会先打印「正在唤醒以便读配置…」(VM 没跑时,每 5 秒
|
||||
打一个点)和「正在探测 OpenCode API 连接…」进度行(flush),
|
||||
几秒无响应时一眼看出是在唤醒/探测而不是死了;`list` 唤醒失败**绝不**回落到
|
||||
UNC 目录扫描(那会触发冷启动卡死),只走 API 或明确报错。
|
||||
`dirs` 的两端目录信息**永远先于** API 探测打印(路径 + 存在性 + skill 数量 +
|
||||
名字摘要),API 分端报「本机 OC / WSL OC」各自的连接结果,后面探测失败也不会
|
||||
吞掉前面的目录展示。
|
||||
`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)的报错里同样附带这份诊断,回答「真实文件在哪」。
|
||||
|
||||
**回归单测**:`python _test_diag.py`(空壳 / 正常 skill / junction 链接 / 断链 /
|
||||
大小写 / WSL UNC 真链接共 15 项断言,2 个文件 symlink 场景在拦截 symlink 的
|
||||
机器上自动 SKIP;含 WSL 场景,首次跑会拉起 VM)。
|
||||
|
||||
---
|
||||
|
||||
## 三、连不上 OC 怎么办
|
||||
|
||||
> 本节只影响 `mcpctl.py`(配置对调 / 热重载走 API)。
|
||||
> `ocskill.py` 的 list / pull / push / delete 已全部直读 skill 目录,**不连 OC API**,
|
||||
> OC 没在运行也能列 / 推 / 拉(写完 OC 起来后目录 watch 会自动热加载)。
|
||||
|
||||
**先看这一条(最常见):有 HTTP 代理时会假报 502。**
|
||||
|
||||
两个脚本已默认**绕过系统代理**直连(`ProxyHandler({})`)。
|
||||
`mcpctl` 默认**绕过系统代理**直连(`ProxyHandler({})`)。
|
||||
如果环境里有 `HTTP_PROXY` / `HTTPS_PROXY`,`urllib` 默认会把 `localhost` 请求也丢给代理,
|
||||
结果是**代理回 502**,看起来像 OC 挂了、其实是被劫走了。
|
||||
确实需要走代理时,设环境变量再运行:`MCPCTL_USE_PROXY=1` / `OCSKILL_USE_PROXY=1`。
|
||||
确实需要走代理时,设环境变量:`MCPCTL_USE_PROXY=1`。
|
||||
|
||||
其余排查:
|
||||
|
||||
@@ -200,7 +223,7 @@ UNC 目录扫描(那会触发冷启动卡死),只走 API 或明确报错
|
||||
读到的口令对它必 401——要么用 TUI 启动的 OC(走 `service.json`),要么
|
||||
`--password` 传启动时设的口令
|
||||
4. `localhost` 不通 → 工具会自动 `wsl hostname -I` 取 WSL 真实 IP 再试
|
||||
5. 全都失败 → `list` 会自动降级为**直接扫目录**,照样能看到 skill 列表
|
||||
5. 全都失败 → skill 读写不受影响:`list` 本来就是**直接扫目录**,照样能看到 skill 列表
|
||||
|
||||
---
|
||||
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# -*- 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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# ---------- 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))
|
||||
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])
|
||||
|
||||
print()
|
||||
print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL)))
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -379,7 +379,7 @@ def swap_files(a: str, b: str) -> None:
|
||||
|
||||
# ---------------------------------------------------------------- reload
|
||||
def _read_password(cfg_dir: Path) -> str:
|
||||
"""口令读取链(与 ocskill._read_server_password 一致):
|
||||
"""口令读取链(service.json → 环境变量 → opencode.json,与 ocskill 旧版一致):
|
||||
service.json(cli.service-config 真实口令,用户名固定 opencode)
|
||||
→ 环境变量 OPENCODE_PASSWORD ?? OPENCODE_SERVER_PASSWORD
|
||||
→ opencode.json 的 server.password(legacy,OC 会忽略,末选)。"""
|
||||
|
||||
@@ -206,6 +206,11 @@ def _confirm_sel(idx: int, names: list[str], root: str, extra: str = "") -> bool
|
||||
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:
|
||||
@@ -330,9 +335,14 @@ def _flow_push() -> None:
|
||||
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", " ")
|
||||
@@ -519,7 +529,7 @@ def menu_skill(shared: dict) -> None:
|
||||
"1 -confirm 跳过;也可贴路径/zip;再选推送到哪)")
|
||||
print(" 3. 拉取技能到本机 OC(选来源即出编号列表:名字直拉;1 / 1. 二次确认,"
|
||||
"1 -confirm 跳过确认)")
|
||||
print(" 4. 查看两端 skill 目录情况(路径 + skill 数量 + 本机/WSL API 分开探测)")
|
||||
print(" 4. 查看两端 skill 目录情况(路径 + skill 数量)")
|
||||
print(" 5. 删除技能(选端即出编号列表:1 -confirm 直删;1 / 1. 打印名字+简介"
|
||||
"二次确认,confirm 删 / cancel 不动;先挑 .trash/ 备份,可恢复)")
|
||||
print(" 提示:任意位置输入 /home 或「返回首页」直接回顶层菜单")
|
||||
|
||||
+302
-273
@@ -4,13 +4,14 @@
|
||||
ocskill —— 打通本机与 WSL OpenCode 的 skill 互通
|
||||
|
||||
做什么:
|
||||
list 运行时连接 WSL 里的 OpenCode,返回它的 skill 列表
|
||||
list 直接读 WSL OC 的 skill 目录,返回它的 skill 列表
|
||||
(2026-09-24 起不再连 OC 的 API —— 直读目录更快、更稳、同源)
|
||||
push 把一个新的 skill 文件(压缩包 / 目录 / 单个 SKILL.md / base64)
|
||||
先解码并校验是不是标准 skill 格式,然后写进 WSL OC 的 skill 目录
|
||||
(压缩包会额外做一次解压;OC 自带目录 watch,写完即热加载)
|
||||
pull 反向操作:把 WSL 里的某个 skill 拷回本机 OC 的 skill 目录
|
||||
delete 删除某个 skill(先挪进 .trash/ 备份,可恢复)
|
||||
dirs 显示两端 skill 目录位置
|
||||
dirs 显示两端 skill 目录位置 + skill 数量
|
||||
|
||||
输入/输出约定(输出不由用户决定):
|
||||
输入 路径/技能名都过一遍 clean_input,粘贴带引号的完整路径也认
|
||||
@@ -18,13 +19,12 @@ ocskill —— 打通本机与 WSL OpenCode 的 skill 互通
|
||||
覆盖 探查式:先看同名文件夹存不存在 —— 不存在就直接写(不问);
|
||||
确有同名才需要 --force,覆盖前统一挪进 .trash/<时间戳>/ 备份
|
||||
|
||||
零依赖:只用 Python 标准库(urllib / zipfile / shutil),无需 pip install。
|
||||
零依赖:只用 Python 标准库(zipfile / shutil),无需 pip install。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
@@ -33,8 +33,6 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -51,7 +49,6 @@ _fix_console()
|
||||
|
||||
# ---------------------------------------------------------------- 常量与默认值
|
||||
DEFAULT_DISTRO = os.environ.get("OC_SKILL_DISTRO", "Ubuntu-24.04")
|
||||
DEFAULT_PORT = int(os.environ.get("OC_PORT", "4096"))
|
||||
SKILL_MD = "SKILL.md"
|
||||
|
||||
# 分页列表:每页 15 个(宝宝级菜单一页看得完,不用滚屏)
|
||||
@@ -193,230 +190,8 @@ def _wsl_ensure_running(distro: str, purpose: str) -> str | None:
|
||||
)
|
||||
|
||||
|
||||
def _read_server_password(config_dir: Path, distro: str = "") -> str:
|
||||
"""口令读取链(与 mcpctl._read_password 一致):
|
||||
service.json(cli.service-config 真实口令,用户名固定 opencode)
|
||||
→ 环境变量 OPENCODE_PASSWORD ?? OPENCODE_SERVER_PASSWORD
|
||||
→ opencode.json 的 server.password(legacy,OC 会忽略,末选)。
|
||||
config_dir 可能是 POSIX 的 /root/...(Windows 侧读不到),自动换 UNC 视图再试;
|
||||
本机 ~/.config/opencode 也作候选(本机 OC 的口令就存在那里)。"""
|
||||
candidates = [config_dir]
|
||||
s = str(config_dir)
|
||||
if os.name == "nt" and s.startswith("/") and distro:
|
||||
candidates.append(Path("\\\\wsl.localhost\\%s%s" % (distro, s.replace("/", "\\"))))
|
||||
candidates.append(Path.home() / ".config" / "opencode")
|
||||
|
||||
for d in candidates:
|
||||
try:
|
||||
data = json.loads((d / "service.json").read_text(encoding="utf-8"))
|
||||
pwd = data.get("password")
|
||||
if pwd:
|
||||
return str(pwd)
|
||||
except Exception:
|
||||
pass
|
||||
env = os.environ.get("OPENCODE_PASSWORD") or os.environ.get("OPENCODE_SERVER_PASSWORD")
|
||||
if env:
|
||||
return env
|
||||
for d in candidates:
|
||||
try:
|
||||
data = json.loads((d / "opencode.json").read_text(encoding="utf-8"))
|
||||
pwd = (data.get("server") or {}).get("password")
|
||||
if pwd:
|
||||
return str(pwd)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _discover_oc_ports() -> list[int]:
|
||||
"""端口发现:tasklist 找 opencode 系进程的 PID,netstat -ano 找这些 PID 的
|
||||
LISTENING TCP 端口。OC 每次启动端口随机(实测 49374),写死 4096 必 10061;
|
||||
--port 4097 启动时默认端口探活扑空,全靠这里补中。
|
||||
进程名前缀匹配:既要普通 `opencode.exe`,也要 service 形态
|
||||
`opencode-service-<pid>-<hash>.exe`(等值匹配 opencode.exe 会全漏)。
|
||||
任何一步失败都返回 [](调用方回退默认端口),绝不抛异常。"""
|
||||
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 _candidate_urls(explicit: str | None, distro: str, port: int,
|
||||
side: str = "all") -> list[str]:
|
||||
"""side 决定探谁:local=只探本机(localhost + 端口发现);
|
||||
wsl=只探 WSL(WSL IP 优先,localhost/127 作镜像网络兜底);
|
||||
all=两者并集(保持旧行为)。显式 --url 时 side 被忽略。"""
|
||||
if explicit:
|
||||
return [explicit.rstrip("/")]
|
||||
urls: list[str] = []
|
||||
if side in ("all", "local"):
|
||||
urls += [
|
||||
f"http://localhost:{port}",
|
||||
f"http://127.0.0.1:{port}",
|
||||
]
|
||||
# 端口发现:默认端口没监听时,补上 opencode.exe 实际 LISTENING 的端口
|
||||
for p in _discover_oc_ports():
|
||||
urls.append(f"http://localhost:{p}")
|
||||
urls.append(f"http://127.0.0.1:{p}")
|
||||
if side in ("all", "wsl"):
|
||||
# Windows 下再试 WSL 的真实 IP(localhost 转发失效时兜底)
|
||||
if os.name == "nt":
|
||||
ip = _wsl_ip(distro)
|
||||
if ip:
|
||||
urls.append(f"http://{ip}:{port}")
|
||||
if side == "wsl":
|
||||
# WSL 侧补 localhost/127:镜像网络模式下 IP 不通时靠转发探中
|
||||
urls += [f"http://localhost:{port}", f"http://127.0.0.1:{port}"]
|
||||
seen, out = set(), []
|
||||
for u in urls:
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def _wsl_ip(distro: str) -> str:
|
||||
"""取 WSL IP;VM 没跑直接返回空(绝不无提示冷启动),
|
||||
且走 _wsl_safe_run(零管道)避免 kill 后 EOF 永久阻塞。"""
|
||||
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 ""
|
||||
|
||||
|
||||
def _build_request(url: str, password: str, method: str = "GET") -> urllib.request.Request:
|
||||
req = urllib.request.Request(url, method=method)
|
||||
req.add_header("accept", "application/json")
|
||||
if password:
|
||||
# OC server 固定用 Basic,用户名必须是 opencode(x/1/admin/root 全 401)
|
||||
raw = base64.b64encode(f"opencode:{password}".encode("utf-8")).decode("ascii")
|
||||
req.add_header("authorization", f"Basic {raw}")
|
||||
return req
|
||||
|
||||
|
||||
# 关键:本机/WSL 的 OC 绝不能走系统代理。
|
||||
# 环境里若有 HTTP_PROXY/HTTPS_PROXY(很多工具会设),urlopen 会把 localhost 请求也丢给代理,
|
||||
# 结果是代理回 502 —— 看起来像 OC 挂了,其实是被劫走了。
|
||||
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
if os.environ.get("OCSKILL_USE_PROXY"):
|
||||
_NO_PROXY_OPENER = urllib.request.build_opener() # 显式要求时才走系统代理
|
||||
|
||||
|
||||
def _try_fetch(url: str, password: str, timeout: float) -> tuple[int, bytes]:
|
||||
"""返回 (status, body)。全程绕过系统代理;鉴权头已是 Basic opencode:口令。"""
|
||||
req = _build_request(url, password)
|
||||
try:
|
||||
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, (exc.read() if hasattr(exc, "read") else b"")
|
||||
except Exception:
|
||||
return 0, b""
|
||||
|
||||
|
||||
def connect_oc(args, side: str = "all") -> tuple[str | None, object]:
|
||||
"""
|
||||
连接 OC,返回 (base_url, payload);side 见 _candidate_urls。
|
||||
依次尝试 /api/skill 与 /skill 两个已知端点;全失败则 payload=None(错误串)。
|
||||
"""
|
||||
password = args.password or _read_server_password(Path(args.remote_config_dir), args.distro)
|
||||
last_err = ""
|
||||
for base in _candidate_urls(args.url, args.distro, args.port, side):
|
||||
for path in ("/api/skill", "/skill"):
|
||||
code, body = _try_fetch(base + path, password, args.timeout)
|
||||
if code == 200:
|
||||
try:
|
||||
return base, json.loads(body.decode("utf-8", errors="replace"))
|
||||
except Exception as exc:
|
||||
last_err = f"{base}{path} 返回非 JSON:{exc}"
|
||||
elif code in (401, 403):
|
||||
last_err = f"{base}{path} 鉴权失败(HTTP {code}),请用 --password 指定 server.password"
|
||||
elif code:
|
||||
last_err = f"{base}{path} 返回 HTTP {code}"
|
||||
else:
|
||||
last_err = f"{base} 连接失败(OC 可能没在运行,试试 opencode serve --port {args.port})"
|
||||
return None, None if not last_err else last_err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- skill 解析
|
||||
def extract_skills(payload) -> list[dict]:
|
||||
"""容错解析 skill 列表:兼容数组 / {skills:[...]} / {data:{...}} 等形态。"""
|
||||
items = None
|
||||
if isinstance(payload, list):
|
||||
items = payload
|
||||
elif isinstance(payload, dict):
|
||||
for key in ("skills", "items", "data", "result", "list"):
|
||||
val = payload.get(key)
|
||||
if isinstance(val, list):
|
||||
items = val
|
||||
break
|
||||
if isinstance(val, dict): # data: { skills: [...] }
|
||||
for inner in val.values():
|
||||
if isinstance(inner, list):
|
||||
items = inner
|
||||
break
|
||||
if items is not None:
|
||||
break
|
||||
if items is None:
|
||||
items = next((v for v in payload.values() if isinstance(v, list)), [])
|
||||
if items is None:
|
||||
return []
|
||||
|
||||
out = []
|
||||
for it in items:
|
||||
if isinstance(it, str):
|
||||
out.append({"name": it, "description": ""})
|
||||
elif isinstance(it, dict):
|
||||
name = it.get("name") or it.get("id") or it.get("title") or ""
|
||||
desc = it.get("description") or it.get("summary") or ""
|
||||
out.append({"name": str(name), "description": str(desc)})
|
||||
return [x for x in out if x["name"]]
|
||||
|
||||
|
||||
def scan_skill_dir(root: str) -> list[dict]:
|
||||
"""API 不可用时的兜底:直接扫目录。"""
|
||||
"""直接扫目录列出 skill —— 列表/push 同名检查/菜单展示共用的 ground truth。"""
|
||||
out = []
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
@@ -683,6 +458,8 @@ def parse_page_cmd(text, page: int, total: int) -> int | None:
|
||||
def render_page(names: list[str], page: int, root_label: str = "") -> tuple[int, int]:
|
||||
"""打印一页 skill 列表:每页 PAGE_SIZE 个,序号跨页全局连续。
|
||||
返回 (实际页码, 总页数)。
|
||||
root_label 同时用作目录根:找不到 SKILL.md 的条目标 ⚠(与推送同名检查同源
|
||||
find_skill_md,选中前就能看出空壳/断链,2026-09-24 新增)。
|
||||
"""
|
||||
total_items = len(names)
|
||||
pages = max(1, -(-total_items // PAGE_SIZE))
|
||||
@@ -696,8 +473,21 @@ def render_page(names: list[str], page: int, root_label: str = "") -> tuple[int,
|
||||
print(" (这个目录下还没有 skill)")
|
||||
else:
|
||||
width = len(str(start + len(chunk)))
|
||||
root_path = Path(root_label) if root_label else None
|
||||
warned = 0
|
||||
for i, n in enumerate(chunk, start + 1):
|
||||
print(f" {str(i).rjust(width)}. {n}")
|
||||
mark = ""
|
||||
if root_path is not None:
|
||||
try:
|
||||
# 与 probe_push / skill_brief 同一个 find_skill_md(含嵌套布局)
|
||||
if find_skill_md(root_path / n) is None:
|
||||
mark = " ⚠缺 SKILL.md"
|
||||
warned += 1
|
||||
except OSError:
|
||||
pass
|
||||
print(f" {str(i).rjust(width)}. {n}{mark}")
|
||||
if warned:
|
||||
print(f" (⚠ = 找不到 SKILL.md:空壳目录或链接断了,选中确认时会打印软链接诊断)")
|
||||
return page, pages
|
||||
|
||||
|
||||
@@ -742,7 +532,14 @@ def probe_push(src: str, distro: str = DEFAULT_DISTRO, b64: bool = False,
|
||||
return {"ok": False, "err": f"输入解析失败:{exc}"}
|
||||
ok, err, meta = validate_skill(candidate)
|
||||
if not ok:
|
||||
return {"ok": False, "err": err}
|
||||
res = {"ok": False, "err": err}
|
||||
# 找不到 SKILL.md -> 附一层软链接诊断(oc/wb 里有些 skill 是链接形式)
|
||||
if SKILL_MD in err:
|
||||
try:
|
||||
res["diag"] = diag_skill_md(candidate)
|
||||
except Exception as diag_exc: # 诊断只增信息,绝不覆盖主错误
|
||||
res["diag"] = [f"诊断失败:{diag_exc}"]
|
||||
return res
|
||||
name = meta.get("name", "")
|
||||
dest = Path(root) if root else Path(wsl_skill_root(distro))
|
||||
dest = dest / name
|
||||
@@ -778,7 +575,15 @@ def skill_brief(name: str, root) -> dict:
|
||||
desc = _frontmatter(md.read_text(encoding="utf-8", errors="replace")).get("description", "")
|
||||
except OSError:
|
||||
desc = ""
|
||||
return {"ok": True, "name": name, "description": desc, "path": str(folder)}
|
||||
return {"ok": True, "name": name, "description": desc, "path": str(folder),
|
||||
"has_md": True, "diag": []}
|
||||
# 缺 SKILL.md -> 附软链接诊断(找出真实文件所在位置)
|
||||
try:
|
||||
diag = diag_skill_md(folder)
|
||||
except Exception as diag_exc: # 诊断只增信息
|
||||
diag = [f"诊断失败:{diag_exc}"]
|
||||
return {"ok": True, "name": name, "description": desc, "path": str(folder),
|
||||
"has_md": False, "diag": diag}
|
||||
|
||||
|
||||
def probe_pull(name: str, distro: str = DEFAULT_DISTRO, root: str | None = None) -> dict:
|
||||
@@ -854,7 +659,13 @@ def find_skill_md(folder: Path, depth: int = 0) -> Path | None:
|
||||
return direct
|
||||
if depth >= 3:
|
||||
return None
|
||||
for child in sorted(folder.iterdir()):
|
||||
try:
|
||||
children = sorted(folder.iterdir())
|
||||
except OSError:
|
||||
# 断链 / 怪异路径(如 symlink 目标解析失败):当「没找到」处理,
|
||||
# 不让诊断、列表页、二次确认被 OSError 炸穿(2026-09-24 单测暴露)
|
||||
return None
|
||||
for child in children:
|
||||
if child.is_dir() and not child.name.startswith("."):
|
||||
found = find_skill_md(child, depth + 1)
|
||||
if found:
|
||||
@@ -862,6 +673,238 @@ def find_skill_md(folder: Path, depth: int = 0) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 软链接诊断(SKILL.md 缺失时)
|
||||
def _is_link(path) -> bool:
|
||||
"""是否是链接 —— Windows 上 symlink 与 junction 都要认。
|
||||
junction 不是 islink(),须看 lstat 的 FILE_ATTRIBUTE_REPARSE_POINT(0x400)。"""
|
||||
p = Path(path)
|
||||
try:
|
||||
if p.is_symlink():
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
if os.name == "nt":
|
||||
try:
|
||||
return bool(os.lstat(p).st_file_attributes & 0x400)
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _link_target(path) -> str:
|
||||
"""读链接目标(symlink / junction);读不到返回空串。"""
|
||||
try:
|
||||
return os.readlink(path)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _unc_distro_rest(path) -> tuple[str, str] | None:
|
||||
"""UNC 路径拆解:\\\\wsl.localhost\\<distro>\\<rest> -> (distro, /<rest>)。
|
||||
非 WSL UNC 返回 None。rest 用 POSIX 风格(WSL 内路径)。"""
|
||||
s = str(path)
|
||||
m = re.match(r"\\\\wsl(?:\$|\.localhost)\\([^\\/]+)(.*)$", s, re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
rest = m.group(2).replace("\\", "/")
|
||||
return m.group(1), (rest if rest.startswith("/") else "/" + rest)
|
||||
|
||||
|
||||
def _resolve_link_real(path: Path, target: str) -> Path | None:
|
||||
"""把链接目标解成真实路径。
|
||||
- POSIX 目标(/root/...)出现在 Windows UNC 视角下 -> 换算回 \\\\wsl.localhost\\<distro>\\...;
|
||||
- Windows 目标(含 \\??\\ / \\\\?\\ 前缀)剥前缀后按绝对/相对解析;
|
||||
- 都读不出来 -> realpath 兜底。"""
|
||||
t = (target or "").strip()
|
||||
if t.startswith("/") and not t.startswith("//"):
|
||||
parts = _unc_distro_rest(path)
|
||||
if parts: # Windows 侧看 WSL 内的 POSIX symlink
|
||||
return Path("\\\\wsl.localhost\\%s%s" % (parts[0], t.replace("/", "\\")))
|
||||
return Path(t) # 本机就是 POSIX(跑在 WSL 里时)
|
||||
for pre in ("\\\\?\\", "\\??\\"):
|
||||
if t.startswith(pre):
|
||||
t = t[len(pre):]
|
||||
break
|
||||
if t:
|
||||
tp = Path(t)
|
||||
if not tp.is_absolute():
|
||||
tp = path.parent / t
|
||||
return tp
|
||||
try:
|
||||
return Path(os.path.realpath(path))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def diag_skill_md(folder) -> list[str]:
|
||||
"""SKILL.md 缺失时的软链接诊断(只读,2026-09-24 按用户反馈新增)。
|
||||
|
||||
oc / wb 里的 skill 有些是软链接形式(不是实体拷贝)—— 目录列得出来,
|
||||
find_skill_md 却找不到文件。这层诊断试着把链接解出来,回答「真实文件在哪」:
|
||||
① 目录本身是不是链接(symlink / junction 都认);
|
||||
② SKILL.md 是不是链接 / 断链 / 大小写不符(skill.md);
|
||||
③ 子项里的链接(POSIX 目标换算回 \\\\wsl.localhost 视图再查);
|
||||
④ 本地解不出且路径是 WSL UNC -> 进 WSL 用 readlink / find 再查一层。
|
||||
返回诊断行列表;有找到真身会标 ✓,全程任何一步失败都跳过不抛异常。
|
||||
"""
|
||||
folder = Path(folder)
|
||||
notes: list[str] = []
|
||||
found_md: list[str] = [] # 找到的 SKILL.md 真实位置
|
||||
|
||||
def _md_at(base: Path, where: str) -> None:
|
||||
"""在 base 下找 SKILL.md,找到记 notes + found_md。"""
|
||||
try:
|
||||
md = find_skill_md(base)
|
||||
except OSError:
|
||||
md = None
|
||||
if md:
|
||||
found_md.append(str(md))
|
||||
notes.append(f" ✓ {where}有 SKILL.md:{md}")
|
||||
|
||||
# ① 目录本身是不是链接
|
||||
base = folder
|
||||
try:
|
||||
dir_exists = folder.exists()
|
||||
except OSError:
|
||||
dir_exists = False
|
||||
if _is_link(folder):
|
||||
tgt = _link_target(folder)
|
||||
if not tgt:
|
||||
# 目标读不出来:WSL 9p symlink 在 Windows 侧 readlink/stat 双失败,
|
||||
# 此时 realpath 只会解出「链接自身」→ 误报断链。不硬解,交给 ④ 层进 WSL 查
|
||||
notes.append(f"目录「{folder.name}」是链接,但 Windows 侧读不到目标"
|
||||
+ ("(疑似 WSL 内的软链接,交由 WSL 内二层诊断)"
|
||||
if _unc_distro_rest(folder) else "(目标读取失败)"))
|
||||
real = None
|
||||
else:
|
||||
real = _resolve_link_real(folder, tgt)
|
||||
notes.append(f"目录「{folder.name}」本身是软链接 -> {tgt}")
|
||||
if real:
|
||||
notes.append(f" 真实位置:{real}")
|
||||
try:
|
||||
real_ok = real.is_dir()
|
||||
except OSError:
|
||||
real_ok = False
|
||||
if real_ok:
|
||||
base = real
|
||||
_md_at(real, "真实位置")
|
||||
else:
|
||||
notes.append(" ✗ 真实位置不存在或不是目录(链接可能已断)")
|
||||
elif not dir_exists and os.path.lexists(folder):
|
||||
notes.append(f"目录「{folder.name}」是断链(链接还在,目标已不存在)")
|
||||
|
||||
# ② SKILL.md 本体:链接 / 断链 / 大小写不符
|
||||
direct = base / SKILL_MD
|
||||
try:
|
||||
direct_ok = direct.is_file()
|
||||
except OSError:
|
||||
direct_ok = False
|
||||
if direct_ok:
|
||||
found_md.append(str(direct)) # 正常命中也要记,否则结论行会误报「仍未找到」
|
||||
notes.append(f" ✓ 目录内直接有 SKILL.md:{direct}")
|
||||
else:
|
||||
if os.path.lexists(direct):
|
||||
tgt = _link_target(direct)
|
||||
real = _resolve_link_real(direct, tgt)
|
||||
notes.append(f"{SKILL_MD} 是链接/断链 -> {tgt or '(目标读取失败)'}"
|
||||
+ (f",真实位置:{real}" if real else ""))
|
||||
if real:
|
||||
try:
|
||||
if real.is_file():
|
||||
found_md.append(str(real))
|
||||
notes.append(f" ✓ 真实文件位置:{real}")
|
||||
elif not real.exists():
|
||||
notes.append(f" ✗ 链接已断(目标不存在):{real}")
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
# 大小写不符(区分大小写的环境会找不到)
|
||||
try:
|
||||
for c in sorted(base.iterdir()):
|
||||
if c.name.lower() == SKILL_MD.lower() and c.name != SKILL_MD:
|
||||
notes.append(f"发现大小写不符的文件:{c.name}(应为 {SKILL_MD},"
|
||||
f"区分大小写的环境会找不到):{c}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ③ 子项里的链接(含指向别处的子目录 / 文件)
|
||||
try:
|
||||
children = sorted(base.iterdir())
|
||||
except OSError as exc:
|
||||
children = []
|
||||
notes.append(f"无法列出目录内容:{exc}")
|
||||
if not children:
|
||||
notes.append("目录是空的(0 个文件/子目录)—— 像空壳目录,不是链接问题")
|
||||
for c in children:
|
||||
if c.name == SKILL_MD or not _is_link(c):
|
||||
continue
|
||||
tgt = _link_target(c)
|
||||
real = _resolve_link_real(c, tgt)
|
||||
line = f"子项「{c.name}」是软链接 -> {tgt or '(目标读取失败)'}"
|
||||
if real:
|
||||
line += f",真实位置:{real}"
|
||||
notes.append(line)
|
||||
if real:
|
||||
try:
|
||||
real_ok = real.exists()
|
||||
except OSError:
|
||||
real_ok = False
|
||||
if real_ok:
|
||||
_md_at(real, f"「{c.name}」的真实位置")
|
||||
else:
|
||||
notes.append(f" ✗ 链接已断(目标不存在):{real}")
|
||||
|
||||
# ④ WSL UNC:本地解不出时进 WSL 再查一层(readlink / find -L)
|
||||
if not found_md and os.name == "nt":
|
||||
parts = _unc_distro_rest(folder)
|
||||
if parts:
|
||||
distro, posix = parts
|
||||
try:
|
||||
running = _wsl_running(distro)
|
||||
except Exception:
|
||||
running = False
|
||||
if running:
|
||||
# wsl.exe 会把原始 commandline 交给 distro shell 二次解析,
|
||||
# script 双引号里的 $var 被外层提前展开(实测 $1/$p 全变空,
|
||||
# 脚本 rc=0 却零输出)—— 脚本走 base64 传输,外层只见到
|
||||
# 无 $ 无引号的管道命令(2026-09-24 单测暴露并实测验证)
|
||||
import shlex as _shlex
|
||||
inner = (
|
||||
f'p={_shlex.quote(posix)}; '
|
||||
'[ -L "$p" ] && echo "LINK $p -> $(readlink "$p")"; '
|
||||
'if [ -d "$p" ]; then '
|
||||
'find -L "$p" -maxdepth 4 -name "SKILL.md" 2>/dev/null | head -5 | sed "s/^/MD /"; '
|
||||
'find "$p" -maxdepth 3 -type l 2>/dev/null | head -10 | while IFS= read -r l; do '
|
||||
'echo "LINK $l -> $(readlink "$l")"; done; fi'
|
||||
)
|
||||
cmd = f"echo {base64.b64encode(inner.encode()).decode()} | base64 -d | sh"
|
||||
try:
|
||||
code, out = _wsl_safe_run(
|
||||
["wsl.exe", "-d", distro, "--", "sh", "-c", cmd],
|
||||
timeout=20)
|
||||
except Exception:
|
||||
code, out = 1, b""
|
||||
if code == 0 and out:
|
||||
for ln in out.decode("utf-8", "replace").splitlines():
|
||||
ln = ln.strip()
|
||||
if ln.startswith("MD "):
|
||||
wsl_md = ln[3:].strip()
|
||||
found_md.append(wsl_md)
|
||||
notes.append(f" ✓ WSL 内找到 SKILL.md:{wsl_md}")
|
||||
elif ln.startswith("LINK "):
|
||||
notes.append(f"WSL 内链接:{ln[5:].strip()}")
|
||||
else:
|
||||
# 不静默跳过(2026-09-24 单测暴露):说明二层没查,让用户知道还能再查
|
||||
notes.append(" ℹ WSL 当前未运行,跳过 WSL 内二层诊断"
|
||||
"(唤醒 WSL 后重试,可查出链接真身;VM 冷启动约 5–20 秒,不自动唤醒)")
|
||||
|
||||
# 结论
|
||||
if not found_md:
|
||||
notes.append("→ 仍未找到 SKILL.md 的真实位置:目录可能是空壳 / 链接目标里也没有文件,"
|
||||
"建议换一个完整的 skill 来源再推。")
|
||||
return notes
|
||||
|
||||
|
||||
def validate_skill(folder: Path) -> tuple[bool, str, dict]:
|
||||
"""确认是不是标准 skill 格式:有 SKILL.md + YAML frontmatter + name。"""
|
||||
md = find_skill_md(folder)
|
||||
@@ -1007,16 +1050,17 @@ def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
# ---------------------------------------------------------------- 子命令
|
||||
def cmd_list(args) -> int:
|
||||
# 读操作也先唤醒 VM(“先给机器启动起来,再去读配置”);
|
||||
# 唤醒失败 → 只走 API,绝不回落到 scan_skill_dir 的 UNC 冷启动
|
||||
# 唤醒失败 → 直接报错返回,绝不回落到 scan_skill_dir 的 UNC 冷启动
|
||||
gate_err = _wsl_ensure_running(args.distro, "list")
|
||||
if gate_err:
|
||||
print(gate_err)
|
||||
return 1
|
||||
root = wsl_skill_root(args.distro)
|
||||
|
||||
# 磁盘扫描 = 与 probe_push / probe_pull / probe_delete 同源的 ground truth。
|
||||
# 2026-09-24 起不再以 OpenCode API 作为主列表:API 返回的是 OC 运行时加载的
|
||||
# skill(可能滞后于磁盘、或连的是别的实例),而推送同名检查直查磁盘目录——
|
||||
# 两者数据源不一致,会出现「技能列表没有该 skill、推送却报已存在同名」的前后矛盾。
|
||||
# 2026-09-24 起不再走 OpenCode API(用户反馈:读 skill 目录不需要 API ——
|
||||
# 连上 WSL 直接查 skill 目录即可,本机的就返回本机 OC 的 skill 目录;
|
||||
# API 返回的是 OC 运行时加载的 skill,可能滞后于磁盘、还多一层连接失败面)。
|
||||
skills = scan_skill_dir(root)
|
||||
source = f"目录扫描({root})"
|
||||
|
||||
@@ -1029,11 +1073,22 @@ def cmd_list(args) -> int:
|
||||
width = max(width, 4)
|
||||
print(f"{'名称'.ljust(width)} 描述")
|
||||
print("-" * (width + 40))
|
||||
warned = False
|
||||
for s in skills:
|
||||
desc = s["description"].replace("\n", " ")
|
||||
if len(desc) > 70:
|
||||
desc = desc[:67] + "..."
|
||||
print(f"{s['name'].ljust(width)} {desc}")
|
||||
# 缺 SKILL.md 的空壳/断链提前标出(与推送同源 find_skill_md,2026-09-24)
|
||||
mark = ""
|
||||
try:
|
||||
if find_skill_md(Path(root) / s["name"]) is None:
|
||||
mark = " ⚠缺 SKILL.md"
|
||||
warned = True
|
||||
except OSError:
|
||||
pass
|
||||
print(f"{s['name'].ljust(width)} {desc}{mark}")
|
||||
if warned:
|
||||
print("(⚠ = 找不到 SKILL.md:空壳目录或链接断了;推送选中确认时会打印软链接诊断)")
|
||||
print(f"\n共 {len(skills)} 个 skill。WSL skill 目录:{root}")
|
||||
return 0
|
||||
|
||||
@@ -1203,8 +1258,7 @@ def cmd_delete(args) -> int:
|
||||
return 0
|
||||
|
||||
def _dirs_line(tag: str, root: str, timeout: float) -> None:
|
||||
"""打印一端 skill 目录情况:路径 + 存在性 + skill 数量(只读,限时防卡死)。
|
||||
目录信息永远先于 API 探测打印 —— 后面探测失败也不会吞掉目录展示。"""
|
||||
"""打印一端 skill 目录情况:路径 + 存在性 + skill 数量(只读,限时防卡死)。"""
|
||||
print(f" {tag}:{root}")
|
||||
ok, res = _guard(lambda: (Path(root).is_dir(), list_skill_names(root)), timeout)
|
||||
if not ok:
|
||||
@@ -1224,13 +1278,11 @@ def _dirs_line(tag: str, root: str, timeout: float) -> None:
|
||||
|
||||
|
||||
def cmd_dirs(args) -> int:
|
||||
"""展示两端 skill 目录情况 + 本机/WSL 分开的 API 连接状态。
|
||||
"""展示两端 skill 目录情况:路径 + 存在性 + skill 数量 + 名字摘要。
|
||||
|
||||
2026-09-22 起按用户反馈增强:原版只打两行路径 + 一个混在一起的
|
||||
「OpenCode API」(本机和 WSL 的候选 URL 混探,连上本机也分不清是谁),
|
||||
看不到「目录是否存在 / 有多少 skill」。现在:
|
||||
1) 两端目录先打:路径 + 存在性 + skill 数量 + 名字摘要(限时防卡死);
|
||||
2) API 分端探测:本机(localhost + 端口发现)/ WSL(WSL IP 优先)各自报。
|
||||
2026-09-24 起不再探测 OC API(用户反馈:读 skill 目录不需要 API,
|
||||
连上 WSL 直接查目录即可,API 探测是画蛇添足)。
|
||||
目录信息限时读取防卡死;WSL 端先唤醒再读。
|
||||
"""
|
||||
local_root = WIN_LOCAL_ROOT
|
||||
wsl_root = wsl_skill_root(args.distro)
|
||||
@@ -1243,20 +1295,6 @@ def cmd_dirs(args) -> int:
|
||||
print(f" 状态:无法访问({gate_err})")
|
||||
else:
|
||||
_dirs_line("WSL OC(push 缺省目标)", wsl_root, 20.0)
|
||||
|
||||
print("正在探测 OpenCode API 连接(本机 / WSL 分开探测)...", flush=True)
|
||||
base, err = connect_oc(args, side="local")
|
||||
print(f" 本机 OC API:已连接 {base}" if base
|
||||
else f" 本机 OC API:未连接({err or '未找到运行中的 OC'})")
|
||||
|
||||
# WSL 是否可探直接复用上面的门控结果 —— 再查一次 _wsl_running 会因
|
||||
# wsl.exe 列表偶发超时(刚唤醒/系统繁忙时)而误报「未运行」
|
||||
if gate_err:
|
||||
print(f" WSL OC API:未探测({gate_err})")
|
||||
else:
|
||||
base, err = connect_oc(args, side="wsl")
|
||||
print(f" WSL OC API:已连接 {base}" if base
|
||||
else f" WSL OC API:未连接({err or '未找到运行中的 OC'})")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1285,7 +1323,7 @@ def _backup_then_remove(dest: Path) -> Path:
|
||||
# ---------------------------------------------------------------- 参数顺序自愈
|
||||
# 共享 flag 定义在主解析器(子命令之前),`ocskill list --distro xxx` 这种
|
||||
# 写法会被 argparse 判为 unrecognized。parse 之前把共享 flag 提到最前。
|
||||
_SHARED_VALUE_FLAGS = {"--distro", "--url", "--port", "--timeout", "--password", "--remote-config-dir"}
|
||||
_SHARED_VALUE_FLAGS = {"--distro"}
|
||||
|
||||
|
||||
def _hoist_shared(argv: list[str]) -> list[str]:
|
||||
@@ -1333,19 +1371,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
),
|
||||
)
|
||||
p.add_argument("--distro", default=DEFAULT_DISTRO, help=f"WSL 发行版名(默认 {DEFAULT_DISTRO})")
|
||||
p.add_argument("--url", default=None, help="显式指定 OC 地址,跳过端口自动发现,如 http://localhost:4097")
|
||||
p.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"OC 端口(默认 {DEFAULT_PORT};非默认端口会自动发现实际监听)")
|
||||
p.add_argument("--timeout", type=float, default=4.0, help="HTTP 超时秒数")
|
||||
p.add_argument("--password", default="", help="OC 口令(默认按 service.json → 环境变量 → opencode.json 顺序自动读)")
|
||||
p.add_argument(
|
||||
"--remote-config-dir",
|
||||
default="/root/.config/opencode",
|
||||
help="读取口令用的配置目录(Windows 侧会自动换 UNC 视图,一般不用改)",
|
||||
)
|
||||
|
||||
sub = p.add_subparsers(dest="cmd")
|
||||
|
||||
sp = sub.add_parser("list", help="连接 WSL 的 OC,返回 skill 列表")
|
||||
sp = sub.add_parser("list", help="直读 WSL OC 的 skill 目录,返回 skill 列表(不走 API)")
|
||||
sp.set_defaults(func=cmd_list)
|
||||
|
||||
sp = sub.add_parser("push", help="推送 skill(zip / 目录 / SKILL.md / base64)到目标终端")
|
||||
@@ -1375,7 +1404,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--yes", "-y", action="store_true", help="跳过交互确认(菜单层已确认时用)")
|
||||
sp.set_defaults(func=cmd_delete)
|
||||
|
||||
sp = sub.add_parser("dirs", help="显示两端 skill 目录与 API 连接状态")
|
||||
sp = sub.add_parser("dirs", help="显示两端 skill 目录情况(路径 + skill 数量)")
|
||||
sp.set_defaults(func=cmd_dirs)
|
||||
|
||||
return p
|
||||
|
||||
Reference in New Issue
Block a user