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:
ypshidifu
2026-09-24 23:09:51 +08:00
parent b827bbbd1d
commit baf169ec19
5 changed files with 527 additions and 290 deletions
+38 -15
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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 会忽略,末选)。"""
+11 -1
View File
@@ -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
View File
@@ -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