f1ed320991
Replace install/uninstall with add/remove, add publish for git ops in ~/.skills, and resolve bin/skiff symlinks so commands work from any cwd. Co-authored-by: Cursor <cursoragent@cursor.com>
105 lines
3.1 KiB
Python
105 lines
3.1 KiB
Python
"""自研 skill 发现与解析。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
||
from skiff.registry import external_skill_path, load_registry
|
||
|
||
|
||
def list_owned_skills() -> list[str]:
|
||
ensure_skills_home()
|
||
if not SKILLS_DIR.is_dir():
|
||
return []
|
||
names: list[str] = []
|
||
for entry in sorted(SKILLS_DIR.iterdir()):
|
||
if not entry.is_dir():
|
||
continue
|
||
if entry.name.startswith("_"):
|
||
continue
|
||
if (entry / "SKILL.md").is_file():
|
||
names.append(entry.name)
|
||
return names
|
||
|
||
|
||
def owned_skill_path(name: str) -> Path:
|
||
path = SKILLS_DIR / name
|
||
if not (path / "SKILL.md").is_file():
|
||
raise SystemExit(f"自研 skill 不存在: {name}")
|
||
return path
|
||
|
||
|
||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
|
||
ensure_skills_home()
|
||
|
||
owned = SKILLS_DIR / name
|
||
if source in (None, "owned") and (owned / "SKILL.md").is_file():
|
||
return owned, "owned"
|
||
|
||
registry = load_registry()
|
||
if source in (None, "registry") and name in registry:
|
||
path = external_skill_path(name, registry[name])
|
||
if not path.exists():
|
||
raise SystemExit(
|
||
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
|
||
)
|
||
return path, "external"
|
||
|
||
if (owned / "SKILL.md").is_file():
|
||
return owned, "owned"
|
||
|
||
raise SystemExit(f"找不到 skill: {name}")
|
||
|
||
|
||
def read_skill_meta(skill_dir: Path) -> dict[str, str]:
|
||
skill_md = skill_dir / "SKILL.md"
|
||
if not skill_md.is_file():
|
||
return {}
|
||
text = skill_md.read_text(encoding="utf-8")
|
||
if not text.startswith("---"):
|
||
return {}
|
||
end = text.find("\n---", 3)
|
||
if end == -1:
|
||
return {}
|
||
frontmatter = text[3:end]
|
||
meta: dict[str, str] = {}
|
||
|
||
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
|
||
if name_match:
|
||
meta["name"] = name_match.group(1).strip().strip("\"'")
|
||
|
||
desc_match = re.search(
|
||
r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)",
|
||
frontmatter,
|
||
re.MULTILINE,
|
||
)
|
||
if desc_match:
|
||
lines = [line.strip() for line in desc_match.group(1).splitlines() if line.strip()]
|
||
meta["description"] = " ".join(lines)
|
||
else:
|
||
inline = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE)
|
||
if inline:
|
||
meta["description"] = inline.group(1).strip().strip("\"'")
|
||
|
||
return meta
|
||
|
||
|
||
def skill_description(name: str) -> str | None:
|
||
meta = read_skill_meta(SKILLS_DIR / name)
|
||
desc = meta.get("description")
|
||
return desc.strip() if desc else None
|
||
|
||
|
||
def validate_skill_name(name: str) -> None:
|
||
import re
|
||
|
||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
||
raise SystemExit(
|
||
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)"
|
||
)
|
||
if name == "_template":
|
||
raise SystemExit("不能使用保留名 _template")
|