1912 lines
79 KiB
Python
1912 lines
79 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
ocskill —— 打通本机与 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 目录位置 + skill 数量
|
||
|
||
输入/输出约定(输出不由用户决定):
|
||
输入 路径/技能名都过一遍 clean_input,粘贴带引号的完整路径也认
|
||
输出 push -> WSL OC skill 目录;pull -> 本机 OC skill 目录
|
||
覆盖 探查式:先看同名文件夹存不存在 —— 不存在就直接写(不问);
|
||
确有同名才需要 --force,覆盖前统一挪进 .trash/<时间戳>/ 备份
|
||
|
||
零依赖:只用 Python 标准库(zipfile / shutil),无需 pip install。
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import binascii
|
||
import glob
|
||
import json
|
||
import os
|
||
import queue
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import zipfile
|
||
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()
|
||
|
||
# ---------------------------------------------------------------- 常量与默认值
|
||
DEFAULT_DISTRO = os.environ.get("OC_SKILL_DISTRO", "Ubuntu-24.04")
|
||
SKILL_MD = "SKILL.md"
|
||
|
||
# 分页列表:每页 15 个(宝宝级菜单一页看得完,不用滚屏)
|
||
PAGE_SIZE = 15
|
||
# agent 终端 skill 目录表(本文件同目录的 agents.md,两列:名字 | 目录)
|
||
AGENTS_MD = Path(__file__).resolve().parent / "agents.md"
|
||
# 内置默认终端:留空回车 = WSL 里的 OC(不在 agents.md 表内)
|
||
DEFAULT_AGENT = {"key": "wsl", "display": "wsl", "dir": ""} # dir 运行时按 distro 解析
|
||
|
||
# 标准 skill 目录:OC 会 watch 每个 config entry 下的 skill/ 与 skills/
|
||
WINDOWS_WSL_ROOT = r"\\wsl.localhost\{distro}\root\.config\opencode\skills"
|
||
LINUX_SKILL_ROOT = "/root/.config/opencode/skills"
|
||
WIN_LOCAL_ROOT = str(Path.home() / ".config" / "opencode" / "skills")
|
||
|
||
|
||
def is_wsl_inside() -> bool:
|
||
"""当前是否已经跑在 WSL/Linux 里。"""
|
||
if os.name != "posix":
|
||
return False
|
||
if sys.platform.startswith("linux"):
|
||
try:
|
||
with open("/proc/version", "r", encoding="utf-8", errors="ignore") as fh:
|
||
if "microsoft" in fh.read().lower():
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return True # 纯 Linux 也走这个分支
|
||
return False
|
||
|
||
|
||
def wsl_skill_root(distro: str) -> str:
|
||
"""WSL 侧 skill 目录(在 Windows 下用 UNC 路径直接读写,无需进 WSL)。"""
|
||
if is_wsl_inside():
|
||
return LINUX_SKILL_ROOT
|
||
return WINDOWS_WSL_ROOT.format(distro=distro)
|
||
|
||
|
||
# ---------------------------------------------------------------- OC 连接
|
||
# ---- WSL 安全执行(与 mcpctl.py 同一套,逐字重复保持三端一致)----
|
||
# 1) wsl.exe 绝不留管道:stdout/stderr 落临时文件。原 capture_output 版在
|
||
# WSL VM 被清理/终止后触发冷启动超时,client 被 kill 但 wslrelay 等后代
|
||
# 进程继承管道句柄,communicate() 等 EOF 永久阻塞 —— 表现为
|
||
# “进入 skill 目录后再也没反应,必须 ctrl+C”。
|
||
# 2) 重活之前先查 --running:VM 没跑绝不无提示冷启动。
|
||
def _wsl_safe_run(argv: list, timeout: float = 10.0) -> tuple[int, bytes]:
|
||
if os.name != "nt":
|
||
return -1, b""
|
||
import tempfile as _tf
|
||
|
||
with _tf.TemporaryFile() as fo, _tf.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() /
|
||
# iterdir 会在系统调用层无限挂起,且 Ctrl+C 打不断(Python 信号要等阻塞的
|
||
# 系统调用返回才处理)。只读探查统一放进 daemon 工作线程限时等待:
|
||
# 超时打印原因并回菜单;主线程等 queue.get(timeout)(Python 层),Ctrl+C 可打断。
|
||
# (与 mcpctl._guard 逐字一致,保持三端同源。)
|
||
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 _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 _wsl_ensure_running(distro: str, purpose: str) -> str | None:
|
||
"""UNC 写之前确保 VM 在跑;返回 None=可继续,字符串=错误信息。"""
|
||
if os.name != "nt" or _wsl_running(distro):
|
||
return None
|
||
print(f"WSL({distro})未运行({purpose}需要访问它),正在唤醒"
|
||
f"(可能需要十几秒,期间每 5 秒打一个点)...", flush=True)
|
||
_run_with_beats(
|
||
lambda: _wsl_safe_run(["wsl.exe", "-d", distro, "--", "true"], timeout=45)
|
||
)
|
||
if _wsl_running(distro):
|
||
print("WSL 已就绪。", flush=True)
|
||
return None
|
||
return (
|
||
f"WSL({distro})唤醒失败,无法访问其 skill 目录。"
|
||
f"请手动执行 wsl.exe -d {distro} 启动后重试。"
|
||
)
|
||
|
||
|
||
def scan_skill_dir(root: str) -> list[dict]:
|
||
"""直接扫目录列出 skill —— 列表/push 同名检查/菜单展示共用的 ground truth。"""
|
||
out = []
|
||
base = Path(root)
|
||
if not base.is_dir():
|
||
return out
|
||
for child in sorted(base.iterdir()):
|
||
if not child.is_dir() or child.name.startswith("."):
|
||
continue
|
||
md = child / SKILL_MD
|
||
desc = ""
|
||
if md.is_file():
|
||
desc = _frontmatter(md.read_text(encoding="utf-8", errors="replace")).get("description", "")
|
||
out.append({"name": child.name, "description": desc})
|
||
return out
|
||
|
||
|
||
BLOCK_MARKERS = {">", "|", ">-", "|-", ">+", "|+", ">", "|"}
|
||
|
||
|
||
def _has_cjk(text: str) -> bool:
|
||
"""是否含中日韩字符(决定块标量折行时要不要补空格)。"""
|
||
return any("\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f" for ch in text)
|
||
|
||
|
||
def _frontmatter(text: str) -> dict:
|
||
"""
|
||
解析 SKILL.md 顶部的 YAML frontmatter(只取扁平 key: value,不引第三方库)。
|
||
会正确处理块标量(description: > / |)——否则描述会显示成一个 ">"。
|
||
"""
|
||
if not text.startswith("---"):
|
||
return {}
|
||
lines = text.splitlines()
|
||
if not lines or lines[0].strip() != "---":
|
||
return {}
|
||
data: dict = {}
|
||
pending_key: str | None = None # 正在收集块标量的 key
|
||
block_lines: list[str] = []
|
||
block_indent: int | None = None
|
||
|
||
def flush() -> None:
|
||
nonlocal pending_key, block_lines, block_indent
|
||
if pending_key is not None:
|
||
parts = [x.strip() for x in block_lines if x.strip()]
|
||
# 中文行之间不补空格,英文单词之间才补
|
||
joiner = "" if _has_cjk("".join(parts)) else " "
|
||
data[pending_key] = joiner.join(parts)
|
||
pending_key, block_lines, block_indent = None, [], None
|
||
|
||
for line in lines[1:]:
|
||
if line.strip() in ("---", "..."):
|
||
flush()
|
||
break
|
||
if pending_key is not None:
|
||
# 块标量的续行:空行或更深缩进的都算
|
||
if line.strip() == "" or (line[:1] in (" ", "\t")):
|
||
block_lines.append(line)
|
||
continue
|
||
flush()
|
||
|
||
m = re.match(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$", line)
|
||
if not m:
|
||
continue
|
||
key, val = m.group(1), m.group(2).strip()
|
||
if val in BLOCK_MARKERS or val.rstrip() in BLOCK_MARKERS:
|
||
pending_key = key
|
||
block_lines = []
|
||
block_indent = None
|
||
continue
|
||
data[key] = val.strip("'\"")
|
||
|
||
flush()
|
||
return data
|
||
|
||
|
||
# ---------------------------------------------------------------- agent 终端表 + 分页(宝宝级拉取)
|
||
_HEADER_K1 = ("名字", "名称", "简称", "name", "key")
|
||
_HEADER_K2 = ("目录", "路径", "dir", "path", "folder")
|
||
_CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8,
|
||
"九": 9, "十": 10, "十一": 11, "十二": 12, "十三": 13, "十四": 14,
|
||
"十五": 15, "十六": 16, "十七": 17, "十八": 18, "十九": 19, "二十": 20}
|
||
_EN_NUM = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
|
||
"seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11,
|
||
"twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
|
||
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
|
||
"twenty": 20}
|
||
|
||
|
||
def _split_bracket(cell: str) -> tuple[str, str]:
|
||
"""'wb(workbuddy)' -> ('wb', 'workbuddy');无括号 -> (原串, 原串)。
|
||
括号形式不限(半角/全角),可出现在任意位置:
|
||
第一对括号前 = 简称(CLI 输入用),括号内 = 全称(CLI 打印用)。
|
||
"""
|
||
s = (cell or "").strip()
|
||
m = re.search(r"[((]([^))]*)[))]", s)
|
||
if not m:
|
||
return s, s
|
||
key = s[: m.start()].strip() or m.group(1).strip() or s
|
||
disp = m.group(1).strip() or key
|
||
return key, disp
|
||
|
||
|
||
def _strip_brackets(text: str) -> str:
|
||
"""输入里的括号一律忽略:'wb(workbuddy)' -> 'wb';'(wb)' -> 'wb'。
|
||
先删成对括号连同内容(剥掉的是括号里的注释性全称);
|
||
若剥空了(内容全在括号里,如 '(wb)'),回退为只删括号字符保留内容;
|
||
最后再清掉任何残留的单侧括号字符。
|
||
"""
|
||
s = (text or "").strip()
|
||
out = re.sub(r"[((][^))]*[))]", "", s).strip()
|
||
if not out:
|
||
out = s
|
||
return re.sub(r"[()()]", "", out).strip()
|
||
|
||
|
||
def load_agent_table(path=None) -> list[dict]:
|
||
"""读 agents.md 终端表(宽松解析,不校验 markdown 完整性)。
|
||
|
||
逐行取「以 | 开头」的行 -> 按 '|' 拆 -> 前两个非空字段 = 名字 | 目录。
|
||
表格没闭合、行首多空格、少分隔符都照常认;新增一行 = 新增一个终端。
|
||
行首必须是 | —— 说明段落里提到的 | 不会混进表。
|
||
跳过:非 | 开头行 / 表头行 / 分隔行 / 缺目录行。文件不存在返回 []。
|
||
返回 [{"key": 简称, "display": 全称, "dir": 目录}, ...]。
|
||
"""
|
||
p = Path(path) if path else AGENTS_MD
|
||
try:
|
||
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
||
except OSError:
|
||
return []
|
||
out: list[dict] = []
|
||
for raw in lines:
|
||
if not raw.strip().startswith("|"):
|
||
continue
|
||
cells = [c.strip() for c in raw.split("|")]
|
||
cells = [c for c in cells if c]
|
||
if len(cells) < 2:
|
||
continue
|
||
k1, k2 = cells[0], cells[1]
|
||
kl, k2l = k1.lower(), k2.lower()
|
||
# 表头行:第一列命中名字类 且 第二列命中路径类
|
||
if any(w in kl for w in _HEADER_K1) and any(w in k2l for w in _HEADER_K2):
|
||
continue
|
||
# 分隔行:字段全是 - : 空格
|
||
if all(set(c) <= set("-: ") for c in (k1, k2)):
|
||
continue
|
||
key, disp = _split_bracket(k1)
|
||
if not key or not k2:
|
||
continue
|
||
if any(t["key"].lower() == key.lower() for t in out):
|
||
continue # 同简称后写不覆盖先写
|
||
out.append({"key": key, "display": disp, "dir": k2})
|
||
return out
|
||
|
||
|
||
def resolve_agent(spec, distro: str = DEFAULT_DISTRO) -> dict:
|
||
"""解析来源/目标终端(CLI --from/--to 与菜单层共用)。判定顺序:
|
||
① 空输入 -> 内置默认 wsl;
|
||
② 是存在的目录 -> 整串当目录用(kind=dir,可直接贴一整个 skill 目录);
|
||
③ 简称 -> agents.md 前缀匹配(精确 key -> key 前缀 -> 全称前缀,大小写不敏感);
|
||
④ 都不中 -> 报错并列出可用简称。
|
||
返回 {"ok", "kind": "default|dir|agent", "label", "dir", "table" / "err"}。
|
||
"""
|
||
spec = clean_input(spec or "")
|
||
table = load_agent_table()
|
||
if not spec:
|
||
return {"ok": True, "kind": "default", "label": "wsl",
|
||
"dir": wsl_skill_root(distro), "table": table}
|
||
try:
|
||
isdir = Path(spec).is_dir()
|
||
except OSError:
|
||
isdir = False
|
||
if isdir:
|
||
return {"ok": True, "kind": "dir", "label": spec, "dir": spec, "table": table}
|
||
q = _strip_brackets(spec).lower()
|
||
if q:
|
||
for mode in ("exact", "key", "disp"):
|
||
for t in table:
|
||
k, d = t["key"].lower(), t["display"].lower()
|
||
hit = (k == q) if mode == "exact" else (
|
||
k.startswith(q) if mode == "key" else d.startswith(q))
|
||
if hit:
|
||
return {"ok": True, "kind": "agent", "label": t["key"],
|
||
"dir": t["dir"], "table": table}
|
||
keys = "、".join(f"{t['key']}({t['display']})" for t in table) or "(agents.md 为空)"
|
||
return {"ok": False, "err": f"无法定位终端「{spec}」。可用简称:wsl(默认)、{keys};"
|
||
f"也可直接贴完整 skill 目录路径。", "table": table}
|
||
|
||
|
||
def list_skill_names(root) -> list[str]:
|
||
"""直接读 skill 文件夹列名字 —— 不启动 OC 服务、不发任何请求。
|
||
只认过了 NAME_RE 的目录名,跳过点前缀(.trash 等 OC 也不加载),按名排序。
|
||
目录不存在/不可读返回 []。
|
||
"""
|
||
try:
|
||
p = Path(root)
|
||
if not p.is_dir():
|
||
return []
|
||
return sorted(
|
||
c.name for c in p.iterdir()
|
||
if c.is_dir() and not c.name.startswith(".") and NAME_RE.match(c.name)
|
||
)
|
||
except OSError:
|
||
return []
|
||
|
||
|
||
def parse_sel(text) -> tuple[int, bool] | None:
|
||
"""编号选择解析(确认协议)-> (全局序号, 是否跳过二次确认);不是编号输入返回 None。
|
||
|
||
协议(推进去 / 取回来两端通用):
|
||
'1' / '1.' -> (1, False) 数字后带点号 = 确认选择它,进二次确认
|
||
(打印 skill 名字 + 简介,confirm 执行 / cancel 回退)
|
||
'1 -confirm' -> (1, True) 跳过二次确认,直接执行('-confirm' 大小写不敏感,
|
||
也认 '1.-confirm' / '1-confirm' / '--confirm')
|
||
名字 / 翻页 / 路径 -> None(交给调用方的其它分支)
|
||
纯 '0' 也命中 (0, False) —— 调用方若把 0 当退出,必须在本函数之前先拦。
|
||
序号范围由调用方按列表长度校验。
|
||
"""
|
||
s = (text or "").strip()
|
||
if not s:
|
||
return None
|
||
m = re.fullmatch(r"(\d+)\s*\.?\s*(?:-{1,2}confirm)?", s, re.IGNORECASE)
|
||
if not m:
|
||
return None
|
||
skip = re.search(r"-{1,2}confirm", s, re.IGNORECASE) is not None
|
||
return int(m.group(1)), skip
|
||
|
||
|
||
def parse_page_cmd(text, page: int, total: int) -> int | None:
|
||
"""翻页命令 -> 目标页码;不是翻页命令返回 None。
|
||
|
||
中文:下一页 / 上一页 / 第N页 / N页(N 可为中文数词,如 第三页);
|
||
英文:next / last / prev / N page(three page / 3 page)。
|
||
纯数字不算翻页 —— 留给「按全局序号选择 skill」。
|
||
|
||
越界语义(2026-09-24 起):
|
||
- 相对翻页(下一页/上一页/next/last):钳制在 [1, total],边界静默停住;
|
||
- 绝对页码(第N页 / N页 / 中文数词 / N page):返回【原始】页码,不钳制 ——
|
||
越界交给调用方报错(「共 X 页,没有第 N 页」)并拒绝跳转,
|
||
不再像旧行为那样把第 999 页悄悄钳到末页造成「翻页没上限」的假象。
|
||
"""
|
||
s = re.sub(r"\s+", "", (text or "")).lower()
|
||
if not s:
|
||
return None
|
||
total = max(1, total)
|
||
clamp = lambda n: max(1, min(total, n)) # noqa: E731
|
||
if s in ("下一页", "下页", "next", "nextpage"):
|
||
return clamp(page + 1)
|
||
if s in ("上一页", "上页", "last", "prev", "previous", "back",
|
||
"lastpage", "prevpage", "previouspage"):
|
||
return clamp(page - 1)
|
||
m = re.fullmatch(r"(第(\d+)页?|(\d+)页)", s) # 第3 / 第3页 / 3页;裸数字不命中
|
||
if m:
|
||
return int(m.group(2) or m.group(3))
|
||
m = re.fullmatch(r"第?([一二三四五六七八九十]+)页", s) # 第三页 / 三页
|
||
if m and m.group(1) in _CN_NUM:
|
||
return _CN_NUM[m.group(1)]
|
||
m = re.fullmatch(r"(\d+|[a-z]+)page", s) # 3 page / three page
|
||
if m:
|
||
tok = m.group(1)
|
||
if tok.isdigit():
|
||
return int(tok)
|
||
return _EN_NUM.get(tok) if tok in _EN_NUM else None
|
||
return 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))
|
||
page = max(1, min(pages, page))
|
||
start = (page - 1) * PAGE_SIZE
|
||
chunk = names[start:start + PAGE_SIZE]
|
||
print(f" skill 列表(共 {total_items} 个,第 {page}/{pages} 页,每页 {PAGE_SIZE} 个)")
|
||
if root_label:
|
||
print(f" 目录:{root_label}")
|
||
if not chunk:
|
||
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):
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------- 输入净化与探查
|
||
_QUOTE_PAIRS = (('"', '"'), ("'", "'"), ("\u201c", "\u201d"), ("\u300c", "\u300d"))
|
||
|
||
def clean_input(raw: str) -> str:
|
||
"""剥掉用户粘贴时带上的引号并去首尾空白。
|
||
|
||
从终端/对话里复制完整 Windows 路径时,两端常带着引号
|
||
(如 "C:\\Users\\...\\skills\\opencode-plugin-dev")。
|
||
不剥掉就会把引号当路径的一部分,直接报「路径不存在」/
|
||
「WSL 端不存在该 skill」—— 报错里能看到引号还挂在路径上。
|
||
|
||
只剥「成对」的引号("..." / '...' / “...” / 「...」);
|
||
单侧引号不动 —— 半剥只会拼出一个更怪、照样不存在的路径,
|
||
不如原样报错,用户一眼能看出是引号没复制全。
|
||
"""
|
||
s = (raw or "").strip()
|
||
for a, b in _QUOTE_PAIRS:
|
||
if len(s) >= 2 and s.startswith(a) and s.endswith(b):
|
||
return s[1:-1].strip()
|
||
return s
|
||
|
||
def probe_push(src: str, distro: str = DEFAULT_DISTRO, b64: bool = False,
|
||
root: str | None = None) -> dict:
|
||
"""推送前探查(只读,不写盘):校验 skill 规范 + 判断目标是否已有同名目录。
|
||
|
||
root 显式指定目标 skill 目录(--to 解析出的终端);缺省仍是 WSL OC 的 skill 目录。
|
||
菜单层据此决定要不要问用户覆盖:
|
||
exists=False -> 不存在同名,直接放(不该问覆盖,问了也是脱裤子放屁)
|
||
exists=True -> 确有同名文件夹,才有资格问一次「是否覆盖」
|
||
返回 {"ok", "err"/"name"/"dest"/"exists"}。
|
||
"""
|
||
src = clean_input(src)
|
||
with tempfile.TemporaryDirectory(prefix="ocskill_probe_") as tmp:
|
||
try:
|
||
candidate = stage_candidate(src, Path(tmp), b64)
|
||
except SystemExit as exc:
|
||
return {"ok": False, "err": str(exc.code or exc)}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {"ok": False, "err": f"输入解析失败:{exc}"}
|
||
ok, err, meta = validate_skill(candidate)
|
||
if not ok:
|
||
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
|
||
try:
|
||
exists = dest.is_dir()
|
||
except OSError:
|
||
exists = False
|
||
return {"ok": True, "name": name, "dest": str(dest), "exists": exists,
|
||
"description": meta.get("description", "")}
|
||
|
||
def skill_brief(name: str, root) -> dict:
|
||
"""读一个 skill 的名字 + 简介 + 路径(只读,供编号选择的二次确认打印)。
|
||
|
||
简介取 SKILL.md frontmatter 的 description(用现成的 find_skill_md + _frontmatter,
|
||
兼容 SKILL.md 在子层 / 块标量描述);目录不存在或名字非法返回 ok=False + err。
|
||
"""
|
||
name = clean_input(name or "")
|
||
if not name:
|
||
return {"ok": False, "err": "技能名为空"}
|
||
if not NAME_RE.match(name):
|
||
return {"ok": False, "err": f"非法 skill 名称「{name}」"}
|
||
folder = Path(root) / name
|
||
try:
|
||
exists = folder.is_dir()
|
||
except OSError:
|
||
exists = False
|
||
if not exists:
|
||
return {"ok": False, "err": f"不存在该 skill:{folder}"}
|
||
desc = ""
|
||
md = find_skill_md(folder)
|
||
if md is not None:
|
||
try:
|
||
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),
|
||
"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:
|
||
"""拉取前探查(只读):来源端是否存在该 skill + 本机是否已有同名目录。
|
||
|
||
root 显式指定来源 skill 目录(--from 解析出的终端);缺省仍是 WSL OC 的 skill 目录。
|
||
"""
|
||
name = clean_input(name)
|
||
if not name:
|
||
return {"ok": False, "err": "技能名为空"}
|
||
if not NAME_RE.match(name):
|
||
return {"ok": False, "err": f"非法 skill 名称「{name}」(只允许字母/数字/点/下划线/连字符,且以字母或数字开头)"}
|
||
src = Path(root) if root else Path(wsl_skill_root(distro))
|
||
src = src / name
|
||
dest = Path(WIN_LOCAL_ROOT) / name
|
||
try:
|
||
src_ok = src.is_dir()
|
||
except OSError:
|
||
src_ok = False
|
||
try:
|
||
local_exists = dest.is_dir()
|
||
except OSError:
|
||
local_exists = False
|
||
if not src_ok:
|
||
return {"ok": False, "err": f"来源端不存在该 skill:{src}"}
|
||
return {"ok": True, "name": name, "dest": str(dest), "exists": local_exists,
|
||
"src": str(src)}
|
||
|
||
def _side_label(base, distro: str = DEFAULT_DISTRO) -> str:
|
||
"""按目录归类侧标签(本机 / WSL / 所选),供删除探查打印用。"""
|
||
b = os.path.normcase(os.path.abspath(str(base)))
|
||
if b == os.path.normcase(os.path.abspath(WIN_LOCAL_ROOT)):
|
||
return "本机"
|
||
if b == os.path.normcase(os.path.abspath(str(wsl_skill_root(distro)))):
|
||
return "WSL"
|
||
return "所选"
|
||
|
||
|
||
def probe_delete(name: str, distro: str = DEFAULT_DISTRO, local: bool = False,
|
||
root: str | None = None) -> dict:
|
||
"""删除前探查(只读):确认目标存在并给出将删除的路径。
|
||
|
||
root 显式指定 skill 目录(菜单选终端后传入的 --dir);缺省按 local 选本机/WSL。
|
||
"""
|
||
name = clean_input(name)
|
||
if not name:
|
||
return {"ok": False, "err": "技能名为空"}
|
||
if not NAME_RE.match(name):
|
||
return {"ok": False, "err": f"非法 skill 名称「{name}」(只允许字母/数字/点/下划线/连字符,且以字母或数字开头)"}
|
||
if root:
|
||
base = Path(clean_input(str(root)))
|
||
side = _side_label(base, distro)
|
||
else:
|
||
base = Path(WIN_LOCAL_ROOT) if local else Path(wsl_skill_root(distro))
|
||
side = "本机" if local else "WSL"
|
||
dest = base / name
|
||
try:
|
||
ok = dest.is_dir()
|
||
except OSError:
|
||
ok = False
|
||
if not ok:
|
||
return {"ok": False, "err": f"{side}端不存在该 skill:{dest}"}
|
||
return {"ok": True, "name": name, "dest": str(dest), "side": side}
|
||
|
||
# ---------------------------------------------------------------- 标准 skill 校验
|
||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||
|
||
|
||
def find_skill_md(folder: Path, depth: int = 0) -> Path | None:
|
||
"""在目录里找 SKILL.md(兼容压缩包自带一层父目录的情况)。"""
|
||
direct = folder / SKILL_MD
|
||
if direct.is_file():
|
||
return direct
|
||
if depth >= 3:
|
||
return None
|
||
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:
|
||
return found
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- skill 注册表(skill.json,2026-09-25)
|
||
# 用户要求的推拉兜底流程:
|
||
# 遇软链接/空壳 -> 不一上来就全盘忙扫:① 先查内存注册表(启动时已把 skill.json
|
||
# 读进内存)-> ② 再快扫内嵌常见终端的 skill 位置(oc / wb / cc-switch / claude …)
|
||
# -> ③ 都没命中才后台扫盘(不阻塞当前推拉),扫完把结果落库 skill.json。
|
||
# skill.json 按「名字 -> 位置」的一堆键值存储;推拉永远优先用户选定的目录,
|
||
# 注册表只在空壳/链接兜底(真身解析)。
|
||
REGISTRY_PATH = Path(__file__).resolve().parent / "skill.json"
|
||
_REG: dict | None = None
|
||
_REG_LOCK = threading.Lock()
|
||
_BG_SCAN_RUNNING = False
|
||
|
||
# 内嵌常见终端 skill 位置(tier1 快扫;相对 home / cwd 解析,路径不存在自动跳过)
|
||
_BUILTIN_HOME_ROOTS = (
|
||
".config/opencode/skills", # opencode(oc)
|
||
".workbuddy/skills", # workbuddy(wb)
|
||
".cc-switch/skills", # cc-switch
|
||
".claude/skills", # claude code
|
||
)
|
||
_BUILTIN_CWD_ROOTS = (
|
||
".workbuddy/skills", # 项目内 wb skill
|
||
".opencode/skills", # 项目内 oc skill
|
||
".claude/skills", # 项目内 claude skill
|
||
)
|
||
|
||
|
||
def builtin_skill_roots(distro: str = DEFAULT_DISTRO) -> list[str]:
|
||
"""内嵌常见终端的 skill 根(tier1 快扫用)—— 先扫这几个,不一上来全盘忙扫。
|
||
|
||
组成:① 内嵌 home/cwd 常见终端位置;② agents.md 终端表里配置的目录
|
||
(用户新增终端自动纳入);③ 环境变量 OC_SKILL_ALT_ROOTS 追加;
|
||
④ WSL 侧两根(仅 VM 已在跑才带 —— 快扫绝不唤醒 WSL,避免冷启动拖慢推拉)。
|
||
全程只拼路径 + 一次 wsl 列表查询;目录不存在由扫描端跳过。
|
||
"""
|
||
home = Path.home()
|
||
roots: list[Path] = [home / p for p in _BUILTIN_HOME_ROOTS]
|
||
try:
|
||
roots += [Path.cwd() / p for p in _BUILTIN_CWD_ROOTS]
|
||
except OSError:
|
||
pass
|
||
for t in load_agent_table():
|
||
if t.get("dir"):
|
||
roots.append(Path(t["dir"]))
|
||
extra = os.environ.get("OC_SKILL_ALT_ROOTS", "")
|
||
roots += [Path(p) for p in extra.split(os.pathsep) if p.strip()]
|
||
if os.name == "nt":
|
||
try:
|
||
wsl_on = _wsl_running(distro)
|
||
except Exception:
|
||
wsl_on = False
|
||
if wsl_on:
|
||
unc = Path("\\\\wsl.localhost\\%s" % distro)
|
||
roots += [unc / "root" / ".config" / "opencode" / "skills",
|
||
unc / "root" / ".workbuddy" / "skills"]
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
for r in roots:
|
||
key = os.path.normcase(os.path.abspath(str(r)))
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(str(r))
|
||
return out
|
||
|
||
|
||
def deep_scan_roots() -> list[str]:
|
||
"""后台扫盘的扩展根(tier2):常见终端之外的兜底位置。
|
||
|
||
只扫浅层、只认名为 skills 的目录(不递归、不全盘):
|
||
home / home/.config / home/.local/share / cwd 下一级子目录里的 skills/。
|
||
—— 市面终端(cursor / cline / roo …)把 skill 放 `~/<终端>/skills` 的,
|
||
这层就能兜住;再深的全盘扫既慢又容易扫进无关目录,不做。
|
||
"""
|
||
out: list[str] = []
|
||
try:
|
||
bases = [Path.home(), Path.home() / ".config",
|
||
Path.home() / ".local" / "share", Path.cwd()]
|
||
except OSError:
|
||
return out
|
||
for base in bases:
|
||
try:
|
||
for child in sorted(base.iterdir()):
|
||
if not child.is_dir() or child.name.startswith("."):
|
||
continue
|
||
s = child / "skills"
|
||
try:
|
||
if s.is_dir():
|
||
out.append(str(s))
|
||
except OSError:
|
||
continue
|
||
except OSError:
|
||
continue
|
||
dedup: list[str] = []
|
||
seen: set[str] = set()
|
||
for r in out:
|
||
key = os.path.normcase(os.path.abspath(r))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
dedup.append(r)
|
||
return dedup
|
||
|
||
|
||
def load_registry() -> dict:
|
||
"""启动时把 skill.json 读到内存(只读一次,之后全走内存缓存 _REG)。"""
|
||
global _REG
|
||
with _REG_LOCK:
|
||
if _REG is None:
|
||
reg: dict = {"version": 1, "updated": "", "skills": {}}
|
||
try:
|
||
if REGISTRY_PATH.is_file():
|
||
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||
if isinstance(data, dict) and isinstance(data.get("skills"), dict):
|
||
reg = data
|
||
reg.setdefault("version", 1)
|
||
reg.setdefault("updated", "")
|
||
except (OSError, ValueError):
|
||
pass # 文件不存在 / 损坏:从空注册表起步,不炸推拉
|
||
_REG = reg
|
||
return _REG
|
||
|
||
|
||
def save_registry() -> None:
|
||
"""把内存注册表落库 skill.json(临时文件 + os.replace 原子写,避免半截文件)。"""
|
||
with _REG_LOCK:
|
||
reg = _REG or {"version": 1, "updated": "", "skills": {}}
|
||
try:
|
||
import datetime as _dt
|
||
reg["updated"] = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
tmp = REGISTRY_PATH.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(reg, ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
os.replace(str(tmp), str(REGISTRY_PATH))
|
||
except OSError as exc:
|
||
print(" (skill.json 落库失败:%s)" % exc)
|
||
|
||
|
||
def remember_skills(found: dict) -> None:
|
||
"""把扫到的 {名字: [位置]} 合并进注册表并落库(只增不删、位置去重)。
|
||
|
||
真身被删/移动的旧位置不主动清(合并语义)—— 查询端会做存在性校验,
|
||
过期条目只是查不到,不会误导。
|
||
"""
|
||
if not found:
|
||
return
|
||
reg = load_registry()
|
||
skills = reg.setdefault("skills", {})
|
||
changed = False
|
||
for name, paths in found.items():
|
||
bucket = skills.setdefault(name, [])
|
||
for p in paths:
|
||
if p not in bucket:
|
||
bucket.append(p)
|
||
changed = True
|
||
if changed:
|
||
save_registry()
|
||
|
||
|
||
def registry_locations(name: str) -> list[str]:
|
||
"""内存注册表里该名字的位置列表(存在性 / SKILL.md 校验由调用方做)。"""
|
||
reg = load_registry()
|
||
return list(reg.get("skills", {}).get(name) or [])
|
||
|
||
|
||
def scan_roots_for_name(name: str, roots: list[str] | None = None,
|
||
distro: str = DEFAULT_DISTRO) -> list[str]:
|
||
"""tier1 快扫:在给定根里按名字找真身目录(<根>/<名> 含 SKILL.md 才算)。
|
||
|
||
roots 缺省 = builtin_skill_roots()(内嵌常见终端位置,秒级、不唤醒 WSL)。
|
||
只读、任何失败跳过该根;返回真身目录字符串列表(去重)。
|
||
"""
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
for r in (roots if roots is not None else builtin_skill_roots(distro)):
|
||
cand = Path(r) / name
|
||
try:
|
||
if find_skill_md(cand) is None:
|
||
continue
|
||
except OSError:
|
||
continue
|
||
key = os.path.normcase(os.path.abspath(str(cand)))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
out.append(str(cand))
|
||
return out
|
||
|
||
|
||
def _scan_root_skills(root: str) -> dict:
|
||
"""扫一个根下所有合格 skill -> {名字: [目录]}(有 SKILL.md 才登记)。"""
|
||
found: dict = {}
|
||
base = Path(root)
|
||
try:
|
||
children = sorted(base.iterdir())
|
||
except OSError:
|
||
return found
|
||
for child in children:
|
||
if not child.is_dir() or child.name.startswith("."):
|
||
continue
|
||
if not NAME_RE.match(child.name):
|
||
continue
|
||
try:
|
||
if find_skill_md(child) is None:
|
||
continue
|
||
except OSError:
|
||
continue
|
||
found.setdefault(child.name, []).append(str(child))
|
||
return found
|
||
|
||
|
||
def start_background_scan(name: str = "") -> bool:
|
||
"""tier3 后台扫盘(不阻塞当前推拉):扫完把最终结果落库 skill.json。
|
||
|
||
已有扫描在跑则直接返回 False(单飞,不叠加线程)。扫描范围 =
|
||
内嵌常见终端根 + 扩展浅层根;UNC 根按 6 秒限时(防 9p 半死拖住线程)。
|
||
扫完打印一行进度(后台输出,不参与交互)。
|
||
"""
|
||
global _BG_SCAN_RUNNING
|
||
with _REG_LOCK:
|
||
if _BG_SCAN_RUNNING:
|
||
return False
|
||
_BG_SCAN_RUNNING = True
|
||
|
||
def _run() -> None:
|
||
global _BG_SCAN_RUNNING
|
||
try:
|
||
roots = list(dict.fromkeys(
|
||
builtin_skill_roots() + deep_scan_roots()))
|
||
merged: dict = {}
|
||
for r in roots:
|
||
if not _is_unc_root(r): # 本机根直扫
|
||
one = _scan_root_skills(r)
|
||
else: # UNC 根限时(半死 VM 不拖线程)
|
||
ok, res = _guard(lambda r=r: _scan_root_skills(r), 6.0)
|
||
one = res if ok and isinstance(res, dict) else {}
|
||
for n, paths in one.items():
|
||
merged.setdefault(n, []).extend(paths)
|
||
remember_skills(merged)
|
||
total = len(load_registry().get("skills", {}))
|
||
print("\n [skill.json] 后台扫盘完成:本次登记 %d 个 skill,"
|
||
"库内共 %d 个名字(已落库 %s)"
|
||
% (len(merged), total, REGISTRY_PATH))
|
||
if name and name not in merged:
|
||
print(" 提示:后台扫盘也没找到「%s」的真身,"
|
||
"可能它只存在于未纳入扫描的位置。" % name)
|
||
finally:
|
||
_BG_SCAN_RUNNING = False
|
||
|
||
threading.Thread(target=_run, daemon=True, name="skill-registry-scan").start()
|
||
return True
|
||
|
||
|
||
def real_src_locations(src: str) -> list[str]:
|
||
"""空壳/链接源的真身位置分层查找(real_src 用;返回目录列表,可能为空)。
|
||
|
||
① 内存注册表(启动已读 skill.json)→ ② 内嵌常见终端位置快扫(命中即落库)
|
||
→ ③ 现有跨根兜底 _alt_skill_dirs(.trash / env 根)→ 都没中调用方再后台扫盘。
|
||
"""
|
||
try:
|
||
p = Path(src)
|
||
name = p.name
|
||
except (OSError, ValueError):
|
||
return []
|
||
|
||
def _same(a, b) -> bool:
|
||
return os.path.normcase(os.path.abspath(str(a))) == \
|
||
os.path.normcase(os.path.abspath(str(b)))
|
||
|
||
def _valid(d) -> bool:
|
||
try:
|
||
return (not _same(d, p)) and Path(d).is_dir() and \
|
||
find_skill_md(Path(d)) is not None
|
||
except OSError:
|
||
return False
|
||
|
||
# ① 内存注册表:零扫盘直接命中(skill.json 启动已进内存)
|
||
hits = [d for d in registry_locations(name) if _valid(d)]
|
||
if hits:
|
||
return hits
|
||
# ② tier1 快扫:内嵌常见终端位置(秒级,不唤醒 WSL)
|
||
quick = scan_roots_for_name(name)
|
||
if quick:
|
||
remember_skills({name: quick}) # 扫到即落库,下次走 ①
|
||
return quick
|
||
# ③ 现有跨根兜底(含 .trash 回收站、OC_SKILL_ALT_ROOTS)
|
||
return [d for d in _alt_skill_dirs(p) if _valid(d)]
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------- 软链接诊断(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 _alt_skill_dirs(folder) -> list[str]:
|
||
"""空壳/断链目录时,跨已知 skill 根目录找同名 skill 的真实目录(真身所在)。
|
||
|
||
2026-09-25 用户实测场景:来源目录(如 .config/opencode/skills/<name>)两端
|
||
都是空壳、也没有任何链接,但大模型照样能调用该 skill —— 因为真身在另一个
|
||
根(~/.workbuddy/skills/<name>/SKILL.md,即 wb 终端)。诊断必须把「原始文件
|
||
位置」找出来,而不是只报「仍未找到」。
|
||
|
||
搜索根(可用环境变量 OC_SKILL_ALT_ROOTS 以 os.pathsep 追加):
|
||
本机 ~/.workbuddy/skills、~/.config/opencode/skills(含 .trash 回收站)、
|
||
当前目录 .workbuddy/skills;Windows 上另查 WSL 端对应两根(folder 是
|
||
WSL UNC 直接查,否则 VM 在跑才查)。
|
||
全程只读、任何一步失败跳过;返回确实含 SKILL.md 的同名目录列表(去重,
|
||
已排除「诊断对象自己」)。SKILL.md 视图由 _alt_skill_md 上层包装。
|
||
"""
|
||
name = Path(folder).name
|
||
if not name:
|
||
return []
|
||
home = Path.home()
|
||
roots: list[Path] = [home / ".workbuddy" / "skills",
|
||
home / ".config" / "opencode" / "skills"]
|
||
extra = os.environ.get("OC_SKILL_ALT_ROOTS", "")
|
||
roots += [Path(p) for p in extra.split(os.pathsep) if p.strip()]
|
||
try:
|
||
roots.append(Path.cwd() / ".workbuddy" / "skills")
|
||
except OSError:
|
||
pass
|
||
if os.name == "nt":
|
||
parts = _unc_distro_rest(Path(folder))
|
||
if parts:
|
||
wsl_on = True # folder 本身就在 WSL 里,同端根一定可查
|
||
distro = parts[0]
|
||
else:
|
||
distro = DEFAULT_DISTRO
|
||
try:
|
||
wsl_on = _wsl_running(distro)
|
||
except Exception:
|
||
wsl_on = False
|
||
if wsl_on:
|
||
unc = Path("\\\\wsl.localhost\\%s" % distro)
|
||
roots += [unc / "root" / ".workbuddy" / "skills",
|
||
unc / "root" / ".config" / "opencode" / "skills"]
|
||
|
||
def _norm(p) -> str:
|
||
return os.path.normcase(os.path.abspath(str(p)))
|
||
|
||
self_dir = _norm(folder)
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
for r in roots:
|
||
# <根>/<同名> + 回收站 <根>/.trash/<时间戳>/<同名>
|
||
cands = [r / name]
|
||
try:
|
||
if (r / ".trash").is_dir():
|
||
cands += [t / name for t in sorted((r / ".trash").iterdir())
|
||
if t.is_dir()]
|
||
except OSError:
|
||
pass
|
||
for cand in cands:
|
||
if _norm(cand) == self_dir:
|
||
continue # 就是诊断对象自己(已知空壳,别自己找自己)
|
||
try:
|
||
md = find_skill_md(cand)
|
||
except OSError:
|
||
md = None
|
||
if md:
|
||
key = _norm(cand)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
out.append(str(cand))
|
||
return out
|
||
|
||
|
||
def _alt_skill_md(folder) -> list[str]:
|
||
"""空壳/断链目录时,跨已知 skill 根目录找同名 skill 的 SKILL.md 真身。
|
||
|
||
_alt_skill_dirs 的上层视图(2026-09-25 抽出:目录列表供推送自动切换真身,
|
||
SKILL.md 列表供诊断打印),行为与抽出前一致。
|
||
"""
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
for d in _alt_skill_dirs(folder):
|
||
try:
|
||
md = find_skill_md(Path(d))
|
||
except OSError:
|
||
md = None
|
||
if md:
|
||
key = os.path.normcase(os.path.abspath(str(md)))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
out.append(str(md))
|
||
return out
|
||
|
||
|
||
def real_src(src: str) -> str | None:
|
||
"""推送源是软链接 / 空壳时,解析出真身目录 —— 自动切换用(2026-09-25 用户要求:
|
||
选了 oc 里的空壳/链接 skill,推送时直接推真身(如 wb),不再提醒手动换来源)。
|
||
|
||
- 链接(symlink / junction)-> 解析链接目标,目标里有 SKILL.md 才切;
|
||
- 目录在但没有 SKILL.md(空壳)-> 分层找真身:内存注册表(skill.json,启动
|
||
已读入)-> 内嵌常见终端位置快扫(oc/wb/cc-switch…,命中即落库)-> 跨根兜底
|
||
_alt_skill_dirs(.trash / env 根);都没命中再启动后台扫盘(不阻塞本次推拉,
|
||
扫完落库,下次直接走注册表)。
|
||
- 有效目录 / zip / SKILL.md 文件 -> 返回 None(用户选定的目录优先,保持原路径不动)。
|
||
全程只读;任何一步失败返回 None,由调用方走原诊断路径。
|
||
"""
|
||
try:
|
||
p = Path(src)
|
||
if not p.is_dir():
|
||
return None
|
||
except OSError:
|
||
return None
|
||
|
||
def _same(a, b) -> bool:
|
||
return os.path.normcase(os.path.abspath(str(a))) == \
|
||
os.path.normcase(os.path.abspath(str(b)))
|
||
|
||
# 1) 链接优先:切到链接目标(wb 的 skill 链到 oc,选 oc 也推 wb 真身)
|
||
try:
|
||
if _is_link(p):
|
||
real = _resolve_link_real(p, _link_target(p))
|
||
if real is not None and not _same(real, p) and real.is_dir() \
|
||
and find_skill_md(real) is not None:
|
||
return str(real)
|
||
except OSError:
|
||
pass
|
||
# 2) 空壳 / 断链:注册表 -> 快扫 -> 跨根兜底的分层查找
|
||
try:
|
||
if find_skill_md(p) is None:
|
||
for d in real_src_locations(str(p)):
|
||
if not _same(d, p):
|
||
return d
|
||
# 都没命中:后台扫盘兜底(tier3,daemon 线程,不阻塞当前推拉;
|
||
# 扫完落库 skill.json,下次同名走内存注册表一步命中)
|
||
start_background_scan(p.name)
|
||
except OSError:
|
||
pass
|
||
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:
|
||
hits = 0
|
||
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)
|
||
hits += 1
|
||
notes.append(f" ✓ WSL 内找到 SKILL.md:{wsl_md}")
|
||
elif ln.startswith("LINK "):
|
||
hits += 1
|
||
notes.append(f"WSL 内链接:{ln[5:].strip()}")
|
||
if not hits:
|
||
# 空结果也要说话(2026-09-25):静默会让人误以为二层没跑
|
||
notes.append(" ℹ WSL 内二层已查:目录里没有 SKILL.md、也没有链接"
|
||
"(WSL 内视角与 Windows 一致)")
|
||
else:
|
||
notes.append(f" ℹ WSL 内二层查询未完成(rc={code}),只按 Windows 视角给结论")
|
||
else:
|
||
# 不静默跳过(2026-09-24 单测暴露):说明二层没查,让用户知道还能再查
|
||
notes.append(" ℹ WSL 当前未运行,跳过 WSL 内二层诊断"
|
||
"(唤醒 WSL 后重试,可查出链接真身;VM 冷启动约 5–20 秒,不自动唤醒)")
|
||
|
||
# 结论:先跨已知根找同名真身(空壳目录的常见真相 —— 真身在 wb 等另一个根,
|
||
# 大模型调用的正是那个文件),找不到才回退「仍未找到」
|
||
if not found_md:
|
||
alts = _alt_skill_md(folder)
|
||
if alts:
|
||
notes.append("→ 本目录里没有 SKILL.md(空壳/断链),但同名 skill 的真身在别处:")
|
||
for a in alts:
|
||
notes.append(f" {a}")
|
||
notes.append(" 大模型实际调用的就是上面这些文件;菜单推送会自动切到上面的"
|
||
"真身目录,不用手动换来源。")
|
||
else:
|
||
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)
|
||
if not md:
|
||
return False, f"不是标准 skill 格式:找不到 {SKILL_MD}", {}
|
||
text = md.read_text(encoding="utf-8", errors="replace")
|
||
if not text.startswith("---"):
|
||
return False, f"{SKILL_MD} 缺少 YAML frontmatter(文件必须以 --- 开头)", {}
|
||
meta = _frontmatter(text)
|
||
if not meta:
|
||
return False, f"{SKILL_MD} 的 frontmatter 为空或无法解析", {}
|
||
name = meta.get("name", "")
|
||
if not name:
|
||
return False, f"{SKILL_MD} frontmatter 缺少必填的 name 字段", {}
|
||
if not NAME_RE.match(name):
|
||
return False, f"非法 skill 名称「{name}」(只允许字母/数字/点/下划线/连字符,且以字母或数字开头)", meta
|
||
if not meta.get("description"):
|
||
return False, f"{SKILL_MD} frontmatter 缺少 description 字段(大模型靠它判断何时调用)", meta
|
||
return True, "", meta
|
||
|
||
|
||
def decode_input(src: str, work: Path, b64: bool) -> Path | None:
|
||
"""
|
||
把用户传进来的输入统一解码成一个本地路径:
|
||
- base64 字符串 -> 解出 zip 或 markdown
|
||
- 其余情况原样返回(文件/目录交给后续步骤处理)
|
||
"""
|
||
if not b64:
|
||
return None
|
||
raw = src.strip()
|
||
try:
|
||
decoded = base64.b64decode(raw, validate=True)
|
||
except (binascii.Error, ValueError) as exc:
|
||
raise SystemExit(f"base64 解码失败:{exc}")
|
||
if decoded[:2] == b"PK": # zipfile magic
|
||
target = work / "decoded.zip"
|
||
target.write_bytes(decoded)
|
||
return target
|
||
target = work / "decoded.md"
|
||
target.write_bytes(decoded)
|
||
return target
|
||
|
||
|
||
def stage_candidate(src: str, work: Path, b64: bool) -> Path:
|
||
"""归一化输入 -> 返回一个包含 SKILL.md 的候选目录。"""
|
||
src = clean_input(src) # 剥掉粘贴时带上的引号("C:\..." -> C:\...)
|
||
resolved = decode_input(src, work, b64)
|
||
path = Path(resolved) if resolved else Path(os.path.expandvars(os.path.expanduser(src)))
|
||
|
||
if not path.exists():
|
||
# 允许直接传一整段 base64(自动嗅探)
|
||
stripped = src.strip()
|
||
if _looks_like_b64(stripped):
|
||
sniff = work / "sniffed.bin"
|
||
try:
|
||
sniff.write_bytes(base64.b64decode(stripped, validate=True))
|
||
path = sniff
|
||
except Exception as exc:
|
||
raise SystemExit(f"输入既不是存在的路径,也不是合法 base64:{exc}")
|
||
else:
|
||
raise SystemExit(f"路径不存在:{path}")
|
||
|
||
if path.is_dir():
|
||
return path
|
||
|
||
# --- 到这里是单个文件:按魔数 / 后缀 / 内容三重嗅探 ---
|
||
if zipfile.is_zipfile(path):
|
||
return _unzip(path, work)
|
||
|
||
suffix = path.suffix.lower()
|
||
if suffix in (".7z", ".rar", ".tar", ".gz", ".tgz"):
|
||
raise SystemExit(f"暂不支持该压缩格式:{path.suffix}(请先转成 .zip)")
|
||
|
||
# 魔数兜底(无后缀或后缀写错的 zip)
|
||
try:
|
||
if path.read_bytes()[:2] == b"PK":
|
||
return _unzip(path, work)
|
||
except OSError:
|
||
pass
|
||
|
||
text = path.read_text(encoding="utf-8", errors="replace")
|
||
compact = "".join(text.split())
|
||
is_b64_text = (
|
||
len(compact) >= 64
|
||
and len(compact) % 4 == 0
|
||
and re.fullmatch(r"[A-Za-z0-9+/=]+", compact) is not None
|
||
)
|
||
|
||
# base64:显式后缀,或者内容本身就是一段 base64
|
||
if suffix in (".b64", ".base64", ".enc") or is_b64_text:
|
||
try:
|
||
decoded = base64.b64decode(compact, validate=True)
|
||
except Exception:
|
||
decoded = b""
|
||
if decoded[:2] == b"PK":
|
||
zipped = work / "sniffed.zip"
|
||
zipped.write_bytes(decoded)
|
||
return _unzip(zipped, work)
|
||
if decoded:
|
||
md_file = work / "decoded.md"
|
||
md_file.write_bytes(decoded)
|
||
text = decoded.decode("utf-8", "replace")
|
||
|
||
# markdown:frontmatter 开头,或者至少是个标题
|
||
stripped = text.lstrip()
|
||
if stripped.startswith("---") or stripped.startswith("#"):
|
||
meta = _frontmatter(text)
|
||
name = meta.get("name") or path.stem
|
||
safe = re.sub(r"[^\w.-]+", "-", name).strip("-") or path.stem
|
||
folder = work / safe
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
(folder / SKILL_MD).write_text(text, encoding="utf-8")
|
||
return folder
|
||
|
||
raise SystemExit(f"无法识别的输入:{path}(支持 .zip / 目录 / SKILL.md / base64 字符串)")
|
||
|
||
|
||
def _unzip(path: Path, work: Path) -> Path:
|
||
dest = work / ("unzipped_" + (path.stem or "archive"))
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
with zipfile.ZipFile(path) as zf:
|
||
_safe_extract(zf, dest)
|
||
return dest
|
||
|
||
|
||
def _looks_like_b64(s: str) -> bool:
|
||
if len(s) < 64 or len(s) % 4 != 0:
|
||
return False
|
||
if not re.fullmatch(r"[A-Za-z0-9+/=\s]+", s):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
|
||
"""解压时防目录穿越(zip slip)。"""
|
||
for member in zf.infolist():
|
||
target = (dest / member.filename).resolve()
|
||
if not str(target).startswith(str(dest.resolve())):
|
||
raise SystemExit(f"压缩包内含非法路径,已拒绝解压:{member.filename}")
|
||
zf.extractall(dest)
|
||
|
||
|
||
# ---------------------------------------------------------------- 子命令
|
||
def cmd_list(args) -> int:
|
||
# 读操作也先唤醒 VM(“先给机器启动起来,再去读配置”);
|
||
# 唤醒失败 → 直接报错返回,绝不回落到 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(用户反馈:读 skill 目录不需要 API ——
|
||
# 连上 WSL 直接查 skill 目录即可,本机的就返回本机 OC 的 skill 目录;
|
||
# API 返回的是 OC 运行时加载的 skill,可能滞后于磁盘、还多一层连接失败面)。
|
||
skills = scan_skill_dir(root)
|
||
source = f"目录扫描({root})"
|
||
|
||
print(f"skill 列表来源:{source}")
|
||
if not skills:
|
||
print("(没有找到任何 skill)")
|
||
return 1
|
||
|
||
width = max(len(s["name"]) for s in skills)
|
||
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] + "..."
|
||
# 缺 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
|
||
|
||
|
||
def _is_unc_root(path) -> bool:
|
||
"""目标是否是 WSL UNC 视图(\\wsl.localhost\\... 或 \\wsl$\\...)——
|
||
这类路径读写前要先唤醒 VM;本机目录(wb/oc 等)完全不碰 WSL。"""
|
||
s = str(path).lower()
|
||
return "wsl.localhost" in s or s.startswith("\\\\wsl$")
|
||
|
||
|
||
def cmd_push(args) -> int:
|
||
# 目标终端:--to 简称(agents.md 前缀)/ 完整目录;缺省 = WSL OC
|
||
tgt = resolve_agent(getattr(args, "dst", "") or "", args.distro)
|
||
if not tgt.get("ok"):
|
||
raise SystemExit(tgt.get("err"))
|
||
root = tgt["dir"]
|
||
root_path = Path(root)
|
||
# UNC 写之前确保 VM 在跑(否则 \\wsl.localhost 冷启动可能挂死)
|
||
if _is_unc_root(root):
|
||
gate_err = _wsl_ensure_running(args.distro, "push")
|
||
if gate_err:
|
||
raise SystemExit(gate_err)
|
||
print(f"目标终端:{tgt['label']} -> {root}")
|
||
if not root_path.is_dir():
|
||
hint = (f"请确认 WSL 发行版名称(当前 {args.distro}),可用 --distro 指定。"
|
||
if tgt["kind"] == "default" else "请检查 agents.md 目录配置(或 --to 传入的路径)。")
|
||
if args.dry_run:
|
||
print(f"(dry-run)目标目录尚不存在:{root}")
|
||
else:
|
||
raise SystemExit(f"找不到目标 skill 目录:{root}\n{hint}")
|
||
|
||
with tempfile.TemporaryDirectory(prefix="ocskill_") as tmp:
|
||
work = Path(tmp)
|
||
# 源是软链接 / 空壳目录 -> 与菜单同一条分层链自动切真身(2026-09-25 CLI 对称;
|
||
# 只对目录生效 —— zip / SKILL.md / base64 走原路径,不动)
|
||
try:
|
||
src_path = clean_input(args.source)
|
||
if Path(src_path).is_dir():
|
||
real = real_src(src_path)
|
||
if real and os.path.normcase(os.path.abspath(real)) != \
|
||
os.path.normcase(os.path.abspath(src_path)):
|
||
print("源是软链接或空壳,已自动切换到真实位置推送:")
|
||
print(f" 原:{src_path}")
|
||
print(f" 新:{real}")
|
||
args.source = real
|
||
except OSError:
|
||
pass
|
||
candidate = stage_candidate(args.source, work, args.base64)
|
||
|
||
ok, err, meta = validate_skill(candidate)
|
||
if not ok:
|
||
print(f"校验失败:{err}")
|
||
return 2
|
||
|
||
name = meta["name"]
|
||
desc = meta.get("description", "")
|
||
print(f"校验通过:标准 skill 格式")
|
||
print(f" 名称:{name}")
|
||
print(f" 描述:{desc[:100]}{'...' if len(desc) > 100 else ''}")
|
||
|
||
# SKILL.md 可能藏在压缩包的一层父目录里,定位到真正含 SKILL.md 的那层
|
||
md = find_skill_md(candidate)
|
||
real_dir = md.parent if md else candidate
|
||
|
||
dest = root_path / name
|
||
if args.dry_run:
|
||
print(f"dry-run:不会写入 {dest}")
|
||
return 0
|
||
|
||
# 探查式覆盖:没有同名文件夹就直接放(目录和内容都没有,覆盖无从谈起);
|
||
# 确有同名才走 --force —— 「要不要覆盖」这一问只在真的存在时才发生。
|
||
if dest.exists():
|
||
if not args.force:
|
||
raise SystemExit(
|
||
f"目标已存在同名 skill:{dest}\n"
|
||
f"加 --force 覆盖(覆盖前会自动挪进 .trash/ 备份,可恢复)。"
|
||
)
|
||
backup = _backup_then_remove(dest)
|
||
print(f"已备份旧版本 -> {backup}")
|
||
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copytree(real_dir, dest, dirs_exist_ok=True)
|
||
print(f"已写入:{dest}")
|
||
print("OC 会 watch skill 目录(约 100ms 去抖后自动重载),无需重启。")
|
||
return 0
|
||
|
||
|
||
def cmd_pull(args) -> int:
|
||
# 来源终端:--from 简称(agents.md 前缀)/ 完整目录;缺省 = WSL OC
|
||
src = resolve_agent(getattr(args, "src", "") or "", args.distro)
|
||
if not src.get("ok"):
|
||
raise SystemExit(src.get("err"))
|
||
root = src["dir"]
|
||
# UNC 读之前同样确保 VM 在跑(没跑则唤醒,唤醒失败给清晰错误)
|
||
if _is_unc_root(root):
|
||
gate_err = _wsl_ensure_running(args.distro, "pull")
|
||
if gate_err:
|
||
raise SystemExit(gate_err)
|
||
|
||
name = clean_input(args.name or "") # 粘贴带引号也认;缺省进分页列表
|
||
if not name:
|
||
# 宝宝级:不记得名字 -> 打印该来源的 skill 列表
|
||
#(直读文件夹,不启动 OC 服务、不发任何请求)
|
||
print(f"来源终端:{src['label']} -> {root}")
|
||
names = list_skill_names(root)
|
||
if not names:
|
||
raise SystemExit(f"来源「{src['label']}」下没有 skill:{root}")
|
||
render_page(names, int(getattr(args, "page", 0) or 1), root)
|
||
print(" 翻页:下一页 / 上一页 / 第N页 / next / last / N page")
|
||
print(f" 记住名字后:python ocskill.py pull <名字> --from {src['label']}")
|
||
return 0
|
||
|
||
print(f"来源终端:{src['label']} -> {root}")
|
||
probe = probe_pull(name, args.distro, root=root)
|
||
if not probe.get("ok"):
|
||
raise SystemExit(probe["err"])
|
||
src_dir = Path(probe["src"])
|
||
|
||
# 来源是软链接 / 空壳 -> 与推送同一条分层链自动切真身(2026-09-25 推拉对称:
|
||
# 注册表 -> 内嵌常见终端快扫 -> 跨根兜底;没命中再后台扫盘,不阻塞本次拉取)
|
||
try:
|
||
need_real = find_skill_md(src_dir) is None
|
||
except OSError:
|
||
need_real = True
|
||
if need_real:
|
||
real = real_src(str(src_dir))
|
||
if real and os.path.normcase(os.path.abspath(real)) != \
|
||
os.path.normcase(os.path.abspath(str(src_dir))):
|
||
print(f"来源是软链接或空壳,已自动切换到真实位置拉取:")
|
||
print(f" 原:{src_dir}")
|
||
print(f" 新:{real}")
|
||
src_dir = Path(real)
|
||
else:
|
||
start_background_scan(name)
|
||
print(f"⚠ 来源目录缺 SKILL.md(空壳或链接断了):{src_dir}")
|
||
print(" 已在后台扫盘找真身(结果落库 skill.json,稍后重试即可自动切)。")
|
||
|
||
# 输出目录不由用户决定:默认直接落本机 OC 的 skill 目录(写完即被 watch 热加载)
|
||
out_dir = Path(args.out) if args.out else Path(WIN_LOCAL_ROOT)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
dest = out_dir / name
|
||
# 探查式覆盖:本机没有同名目录就直接放(不该问覆盖);有才需要 --force
|
||
if dest.exists():
|
||
if not args.force:
|
||
raise SystemExit(
|
||
f"本机已存在同名 skill:{dest}\n"
|
||
f"加 --force 覆盖(覆盖前会自动挪进 .trash/ 备份,可恢复)。"
|
||
)
|
||
backup = _backup_then_remove(dest)
|
||
print(f"已备份旧版本 -> {backup}")
|
||
shutil.copytree(src_dir, dest)
|
||
print(f"已取回:{dest}")
|
||
return 0
|
||
|
||
|
||
def cmd_delete(args) -> int:
|
||
"""删除 skill(补齐管理闭环:列表 / 推送 / 拉取 / 删除 / 覆盖更新)。
|
||
|
||
目标目录三选一(与 push 的 --to 同语义):--dir 显式目录 > --local 本机 > 缺省 WSL 端。
|
||
删除绝不直接 rmtree:先挪进 <skills根>/.trash/<时间戳>/,删错了可恢复。
|
||
--yes 跳过交互确认(菜单层已经问过用户时传入)。
|
||
"""
|
||
name = clean_input(args.name)
|
||
if not name:
|
||
raise SystemExit("技能名为空。")
|
||
|
||
dir_arg = clean_input(getattr(args, "dir", "") or "")
|
||
root = Path(dir_arg) if dir_arg else (
|
||
Path(WIN_LOCAL_ROOT) if args.local else Path(wsl_skill_root(args.distro)))
|
||
|
||
# UNC 访问:先唤醒再读,否则没跑的 VM 会被冷启动拖死(本机目录不碰 WSL)
|
||
if _is_unc_root(root):
|
||
gate_err = _wsl_ensure_running(args.distro, "delete")
|
||
if gate_err:
|
||
raise SystemExit(gate_err)
|
||
|
||
probe = probe_delete(name, args.distro, args.local, root=dir_arg or None)
|
||
if not probe["ok"]:
|
||
raise SystemExit(probe["err"])
|
||
dest = Path(probe["dest"])
|
||
side = probe["side"]
|
||
|
||
if not args.yes:
|
||
try:
|
||
ans = input(
|
||
f"确认删除{side} skill「{name}」?\n"
|
||
f" 路径:{dest}\n"
|
||
f" (会先挪进 .trash/ 备份,可恢复)[y/N]: "
|
||
).strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
print("\n已取消删除。")
|
||
return 0
|
||
if ans.lower() not in ("y", "yes", "是"):
|
||
print("已取消删除。")
|
||
return 0
|
||
|
||
backup = _backup_then_remove(dest)
|
||
print(f"已删除{side} skill「{name}」(备份在 {backup},需要时可移回原位)")
|
||
return 0
|
||
|
||
def _dirs_line(tag: str, root: str, timeout: float) -> None:
|
||
"""打印一端 skill 目录情况:路径 + 存在性 + skill 数量(只读,限时防卡死)。"""
|
||
print(f" {tag}:{root}")
|
||
ok, res = _guard(lambda: (Path(root).is_dir(), list_skill_names(root)), timeout)
|
||
if not ok:
|
||
if res == "TIMEOUT":
|
||
print(f" 状态:读取超时(>{timeout:g}s,WSL UNC 无响应;不再无限挂起)")
|
||
else:
|
||
print(f" 状态:读取失败({res})")
|
||
return
|
||
isdir, names = res
|
||
if isdir:
|
||
print(f" 状态:存在,{len(names)} 个 skill")
|
||
if names:
|
||
shown = "、".join(names[:8]) + ("…" if len(names) > 8 else "")
|
||
print(f" skill:{shown}")
|
||
else:
|
||
print(" 状态:目录不存在(首次推送/拉取时会自动创建)")
|
||
|
||
|
||
def cmd_dirs(args) -> int:
|
||
"""展示两端 skill 目录情况:路径 + 存在性 + skill 数量 + 名字摘要。
|
||
|
||
2026-09-24 起不再探测 OC API(用户反馈:读 skill 目录不需要 API,
|
||
连上 WSL 直接查目录即可,API 探测是画蛇添足)。
|
||
目录信息限时读取防卡死;WSL 端先唤醒再读。
|
||
"""
|
||
local_root = WIN_LOCAL_ROOT
|
||
wsl_root = wsl_skill_root(args.distro)
|
||
|
||
print("两端 skill 目录情况:")
|
||
_dirs_line("本机 OC(pull 默认落点)", local_root, 8.0)
|
||
gate_err = _wsl_ensure_running(args.distro, "dirs")
|
||
if gate_err:
|
||
print(f" WSL OC(push 缺省目标):{wsl_root}")
|
||
print(f" 状态:无法访问({gate_err})")
|
||
else:
|
||
_dirs_line("WSL OC(push 缺省目标)", wsl_root, 20.0)
|
||
return 0
|
||
|
||
|
||
def _stamp() -> str:
|
||
import datetime
|
||
|
||
return datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||
|
||
def _backup_then_remove(dest: Path) -> Path:
|
||
"""覆盖前把同名目录挪进 `<skills根>/.trash/<时间戳>/`,绝不直接 rmtree。
|
||
|
||
- 可恢复:覆盖错了还能从 .trash 里捞回来
|
||
- 不污染:统一放点前缀 .trash/ 下,OC 的 skill loader 与 scan_skill_dir
|
||
都跳过 `.` 开头的目录;旧的 `<name>.bak-<ts>` 平级备份会带 SKILL.md,
|
||
反而可能被 OC 当成一个多余的 skill 加载出来
|
||
"""
|
||
trash = dest.parent / ".trash" / _stamp()
|
||
trash.mkdir(parents=True, exist_ok=True)
|
||
target = trash / dest.name
|
||
if target.exists(): # 同秒内重复覆盖
|
||
target = trash / f"{dest.name}-{_stamp()}"
|
||
shutil.move(str(dest), str(target))
|
||
return target
|
||
|
||
|
||
# ---------------------------------------------------------------- 参数顺序自愈
|
||
# 共享 flag 定义在主解析器(子命令之前),`ocskill list --distro xxx` 这种
|
||
# 写法会被 argparse 判为 unrecognized。parse 之前把共享 flag 提到最前。
|
||
_SHARED_VALUE_FLAGS = {"--distro"}
|
||
|
||
|
||
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:
|
||
head.append(tok)
|
||
if "=" in tok:
|
||
i += 1
|
||
elif i + 1 < n:
|
||
head.append(argv[i + 1])
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
else:
|
||
rest.append(tok)
|
||
i += 1
|
||
return head + rest
|
||
|
||
|
||
# ---------------------------------------------------------------- 入口
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
prog="ocskill",
|
||
description="打通本机与 WSL OpenCode 的 skill 互通(list / push / pull / dirs)",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=(
|
||
"示例:\n"
|
||
" python ocskill.py list\n"
|
||
" python ocskill.py push .\\my-skill.zip\n"
|
||
" python ocskill.py push .\\my-skill\\SKILL.md\n"
|
||
" python ocskill.py push <base64字符串> --base64\n"
|
||
" python ocskill.py push .\\my-skill --dry-run\n"
|
||
" python ocskill.py pull # 不记得名字:打印第一页列表(每页15个)\n"
|
||
" python ocskill.py pull --from wb --page 2 # 列 wb(workbuddy) 终端第 2 页\n"
|
||
" python ocskill.py pull java-expert # 记得名字:直接拉回本机 OC skill 目录\n"
|
||
" python ocskill.py push .\\my-skill --to wb # 推到 wb(workbuddy) 终端\n"
|
||
" python ocskill.py delete java-expert # 删 WSL 端(先挪 .trash/)\n"
|
||
" python ocskill.py delete java-expert --local # 删本机\n"
|
||
),
|
||
)
|
||
p.add_argument("--distro", default=DEFAULT_DISTRO, help=f"WSL 发行版名(默认 {DEFAULT_DISTRO})")
|
||
|
||
sub = p.add_subparsers(dest="cmd")
|
||
|
||
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)到目标终端")
|
||
sp.add_argument("source", help="skill 文件路径、目录、zip 或 base64 字符串")
|
||
sp.add_argument("--to", dest="dst", default="",
|
||
help="目标终端:agents.md 简称(wb/oc…前缀)或完整目录(缺省 wsl)")
|
||
sp.add_argument("--base64", action="store_true", help="把 source 当作 base64 内容解码")
|
||
sp.add_argument("--force", action="store_true", help="覆盖同名 skill(先自动备份)")
|
||
sp.add_argument("--dry-run", action="store_true", help="只校验格式,不写入")
|
||
sp.set_defaults(func=cmd_push)
|
||
|
||
sp = sub.add_parser("pull", help="拉取 skill 回本机 OC skill 目录;不带名字则打印来源分页列表")
|
||
sp.add_argument("name", nargs="?", default="",
|
||
help="skill 名称(可带引号,会自动剥掉;缺省打印分页列表,每页 15 个)")
|
||
sp.add_argument("--from", dest="src", default="",
|
||
help="来源终端:agents.md 简称(wb/oc…前缀)或完整目录(缺省 wsl)")
|
||
sp.add_argument("--page", type=int, default=1, help="列表页码(缺省第 1 页,每页 15 个)")
|
||
sp.add_argument("--out", default=None, help=f"输出目录(默认本机 OC skill 目录:{WIN_LOCAL_ROOT})")
|
||
sp.add_argument("--force", action="store_true", help="覆盖本机同名目录(先挪进 .trash/ 备份)")
|
||
sp.set_defaults(func=cmd_pull)
|
||
|
||
sp = sub.add_parser("delete", help="删除 skill(先挪进 .trash/ 备份,可恢复)")
|
||
sp.add_argument("name", help="skill 名称(可带引号,会自动剥掉)")
|
||
sp.add_argument("--local", action="store_true", help="删本机的 skill(缺省删 WSL 端)")
|
||
sp.add_argument("--dir", default="",
|
||
help="显式指定 skill 目录(与 push --to 同语义;给了它就忽略 --local)")
|
||
sp.add_argument("--yes", "-y", action="store_true", help="跳过交互确认(菜单层已确认时用)")
|
||
sp.set_defaults(func=cmd_delete)
|
||
|
||
sp = sub.add_parser("dirs", help="显示两端 skill 目录情况(路径 + skill 数量)")
|
||
sp.set_defaults(func=cmd_dirs)
|
||
|
||
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
|
||
load_registry() # 启动时把 skill.json 读进内存(读失败走空表,不影响执行)
|
||
try:
|
||
return args.func(args)
|
||
except KeyboardInterrupt:
|
||
print("\n已取消。")
|
||
return 130
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|