169 lines
4.8 KiB
Python
169 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
||
"""Print a human-facing builtin skill list: one-liner + copy-paste example.
|
||
|
||
Reads each skill's README.md. Do not substitute SKILL.md descriptions.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
_HEADING_RE = re.compile(r"^#{1,6}\s+")
|
||
_FENCE_RE = re.compile(r"^```(\w+)?\s*$")
|
||
|
||
|
||
def skills_dir() -> Path:
|
||
here = Path(__file__).resolve()
|
||
repo_skills = here.parents[2]
|
||
if (repo_skills / "pouch" / "SKILL.md").is_file():
|
||
return repo_skills
|
||
return Path.home() / ".pouch" / "skills"
|
||
|
||
|
||
def _strip_md(text: str) -> str:
|
||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||
return " ".join(text.split())
|
||
|
||
|
||
def _first_sentence(text: str) -> str:
|
||
text = _strip_md(text).strip()
|
||
for sep in ("。", "!", "?"):
|
||
idx = text.find(sep)
|
||
if idx != -1:
|
||
return text[: idx + 1]
|
||
return text.rstrip("::") + ("。" if text else "")
|
||
|
||
|
||
def _lede(readme: str) -> str:
|
||
lines = readme.splitlines()
|
||
i = 0
|
||
while i < len(lines) and (
|
||
not lines[i].strip() or _HEADING_RE.match(lines[i])
|
||
):
|
||
i += 1
|
||
chunk: list[str] = []
|
||
while i < len(lines):
|
||
raw = lines[i]
|
||
stripped = raw.strip()
|
||
if not stripped:
|
||
break
|
||
if _HEADING_RE.match(raw) or stripped.startswith(("- ", "* ", "> ")):
|
||
break
|
||
chunk.append(stripped)
|
||
i += 1
|
||
return _first_sentence(" ".join(chunk))
|
||
|
||
|
||
def _section_body(readme: str, titles: tuple[str, ...]) -> str | None:
|
||
lines = readme.splitlines()
|
||
start = None
|
||
for i, line in enumerate(lines):
|
||
if line.startswith("## ") and line[3:].strip() in titles:
|
||
start = i + 1
|
||
break
|
||
if start is None:
|
||
return None
|
||
end = len(lines)
|
||
for j in range(start, len(lines)):
|
||
if lines[j].startswith("## "):
|
||
end = j
|
||
break
|
||
return "\n".join(lines[start:end])
|
||
|
||
|
||
def _first_example(readme: str) -> str:
|
||
body = _section_body(readme, ("使用示例", "常见用法"))
|
||
search = body if body is not None else readme
|
||
lines = search.splitlines()
|
||
i = 0
|
||
while i < len(lines):
|
||
fence = _FENCE_RE.match(lines[i].strip())
|
||
if not fence:
|
||
i += 1
|
||
continue
|
||
i += 1
|
||
block: list[str] = []
|
||
while i < len(lines) and not lines[i].strip().startswith("```"):
|
||
block.append(lines[i])
|
||
i += 1
|
||
for raw in block:
|
||
stripped = raw.strip()
|
||
if not stripped or stripped.startswith("#"):
|
||
continue
|
||
return stripped
|
||
i += 1
|
||
return ""
|
||
|
||
|
||
def iter_skills(root: Path) -> list[Path]:
|
||
skills: list[Path] = []
|
||
if not root.is_dir():
|
||
return skills
|
||
for entry in sorted(root.iterdir()):
|
||
if not entry.is_dir() or entry.name.startswith("_"):
|
||
continue
|
||
if (entry / "SKILL.md").is_file():
|
||
skills.append(entry)
|
||
return skills
|
||
|
||
|
||
def catalog_rows(root: Path) -> list[tuple[str, str, str]]:
|
||
rows: list[tuple[str, str, str]] = []
|
||
errors: list[str] = []
|
||
for skill in iter_skills(root):
|
||
readme_path = skill / "README.md"
|
||
if not readme_path.is_file():
|
||
errors.append(f"{skill.name}: 缺少 README.md")
|
||
continue
|
||
text = readme_path.read_text(encoding="utf-8")
|
||
lede = _lede(text)
|
||
example = _first_example(text)
|
||
if not lede:
|
||
errors.append(f"{skill.name}: README 缺少一句话说明")
|
||
if not example:
|
||
errors.append(f"{skill.name}: README 缺少可复制的使用示例")
|
||
if lede and example:
|
||
rows.append((skill.name, lede, example))
|
||
if errors:
|
||
raise SystemExit("catalog 失败:\n " + "\n ".join(errors))
|
||
return rows
|
||
|
||
|
||
def format_table(rows: list[tuple[str, str, str]]) -> str:
|
||
lines = [
|
||
"| Skill | 做什么 | 可以这么说 |",
|
||
"| --- | --- | --- |",
|
||
]
|
||
for name, lede, example in rows:
|
||
example = example.replace("|", "\\|")
|
||
lede = lede.replace("|", "\\|")
|
||
lines.append(f"| {name} | {lede} | `{example}` |")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="列出 builtin skill 的一句话说明和可复制示例"
|
||
)
|
||
parser.add_argument(
|
||
"--skills-dir",
|
||
type=Path,
|
||
default=None,
|
||
help="skill 根目录(默认本仓库 skills/ 或 ~/.pouch/skills)",
|
||
)
|
||
args = parser.parse_args()
|
||
root = args.skills_dir.resolve() if args.skills_dir else skills_dir()
|
||
if not root.is_dir():
|
||
print(f"找不到 skills 目录: {root}", file=sys.stderr)
|
||
return 2
|
||
sys.stdout.write(format_table(catalog_rows(root)))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|