feat: rename skills/skiff to pouch and move ACK state under .pouch
Use ~/.pouch, the pouch CLI, and .pouch.yaml as the SSOT container. Keep the inner skills/ packages, and store ACK project state in .pouch/ack instead of docs/ack.
This commit is contained in:
+253
@@ -0,0 +1,253 @@
|
||||
# pouch
|
||||
|
||||
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/pouch # 本仓库根目录
|
||||
./install.sh # 软链到 ~/.local/bin/pouch
|
||||
```
|
||||
|
||||
确保 `~/.local/bin` 在 `PATH` 中。
|
||||
|
||||
## 命令风格
|
||||
|
||||
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
|
||||
统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。
|
||||
|
||||
```bash
|
||||
# 浏览可用自研 skill
|
||||
pouch add --list
|
||||
|
||||
# 装到当前项目 / 全局
|
||||
pouch add discussion-notes -a cursor -y
|
||||
pouch add discussion-notes -a cursor -g -y
|
||||
|
||||
# 卸载
|
||||
pouch remove discussion-notes -a cursor -y
|
||||
pouch rm discussion-notes -g -y
|
||||
|
||||
# 改完 skill 后提交推送(在任意目录执行,操作 ~/.pouch)
|
||||
pouch publish skills/discussion-notes -m "update discussion-notes" --push
|
||||
```
|
||||
|
||||
开发时也可直接运行:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/path/to/pouch python3 -m pouch <command>
|
||||
```
|
||||
|
||||
## 首次安装
|
||||
|
||||
```bash
|
||||
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||
~/.pouch/install.sh
|
||||
```
|
||||
|
||||
`install.sh` 会安装 CLI,并自动执行 `pouch bootstrap`,将本仓库的 `pouch` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑:
|
||||
|
||||
```bash
|
||||
pouch bootstrap
|
||||
```
|
||||
|
||||
## 命令参考
|
||||
|
||||
### 查看
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
||||
| `pouch status [--target all\|cursor\|claude\|codex\|agents]` | 安装状态总览 |
|
||||
|
||||
### 项目初始化
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
|
||||
| `pouch update` | 在 `~/.pouch` 执行 `git pull`,更新 pouch 自身 |
|
||||
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
|
||||
|
||||
### Skill 安装
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
|
||||
| `pouch select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
|
||||
| `pouch remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
|
||||
| `pouch add --list` | 列出可用 builtin skill |
|
||||
| `pouch publish [paths] -m MSG [--push]` | 在 ~/.pouch 内 git add/commit/push |
|
||||
|
||||
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
|
||||
|
||||
全局目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `~/.cursor/skills/` |
|
||||
| claude | `~/.claude/skills/` |
|
||||
| codex | `~/.codex/skills/` |
|
||||
| agents | `~/.agents/skills/`(agents 标准目录,覆盖 OMP) |
|
||||
|
||||
### 预置 Catalog Source
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
|
||||
| `pouch fetch <name>` | 克隆或更新 catalog source checkout |
|
||||
| `pouch add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
|
||||
| `pouch add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
|
||||
|
||||
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
|
||||
会显示在 `pouch select` 的候选列表中。`path` 可以直接指向含
|
||||
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
|
||||
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`pouch add <name>`
|
||||
安装全部,`pouch select` 则展开为 `<name>/<skill>` 供分别勾选。同一
|
||||
`repo` 与 `ref` 共享一份 Git checkout。
|
||||
|
||||
### 交互式批量安装
|
||||
|
||||
```bash
|
||||
pouch select # 当前项目,全部 Agent
|
||||
pouch select -a codex # 当前项目,仅 Codex
|
||||
pouch select -g # 全局安装
|
||||
pouch select --project ~/code/app # 指定项目
|
||||
```
|
||||
|
||||
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
|
||||
取消。普通 `pouch select` 只安装到项目,并在每一项旁只读显示各 Agent 的
|
||||
全局安装状态;`pouch select -g` 只安装到全局。已经安装到目标范围的 skill
|
||||
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `pouch remove`。如果全局
|
||||
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
|
||||
项目模式会把成功选择的项目写入 `.pouch.yaml`。非交互环境请使用
|
||||
`pouch add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
|
||||
skill 条目中,后续 `pouch sync` 不会扩散到其他 Agent。
|
||||
|
||||
### Custom source(多-skill 仓库)
|
||||
|
||||
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
|
||||
|
||||
```bash
|
||||
# Git 仓库,默认 clone 到 ~/.local/share/pouch/sources/company
|
||||
pouch source add company \
|
||||
git@git.company.com:platform/agent-skills.git \
|
||||
--ref main \
|
||||
--skills-path internal/skills
|
||||
|
||||
# 或接入已有本地仓库
|
||||
pouch source add company \
|
||||
--local ~/code/company-agent-skills \
|
||||
--skills-path skills
|
||||
|
||||
pouch source list
|
||||
pouch source fetch company
|
||||
pouch list --source company
|
||||
pouch add company/code-review -g -a codex
|
||||
```
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
|
||||
| `pouch source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
|
||||
| `pouch source list` / `show <name>` | 查看 source |
|
||||
| `pouch source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
|
||||
| `pouch source remove <name>` | 移除配置并保留 checkout |
|
||||
|
||||
配置保存在 `~/.config/pouch/config.yaml`。Git/SSH 认证复用本机 Git 配置,
|
||||
pouch 不保存 token。可以使用 `company/code-review`,也可以使用
|
||||
`pouch add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
|
||||
|
||||
### 项目级
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch enable <name> [--target all] [--project <dir>]` | 写入 `.pouch.yaml` 并创建项目软链 |
|
||||
| `pouch disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
|
||||
| `pouch sync [--target all] [--project <dir>]` | 按 `.pouch.yaml` 重建软链 |
|
||||
|
||||
项目目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `<project>/.agents/skills/` |
|
||||
| claude | `<project>/.claude/skills/` |
|
||||
| codex | `<project>/.agents/skills/` |
|
||||
| agents | `<project>/.agents/skills/`(与 cursor/codex 共用路径,软链幂等) |
|
||||
|
||||
### 脚手架与健康检查
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pouch create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
|
||||
| `pouch check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
|
||||
| `pouch finalize <name>` | 校验草稿并移动到正式 `skills/` |
|
||||
| `pouch doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
||||
|
||||
## 常用工作流
|
||||
|
||||
### 新建并全局启用自研 skill
|
||||
|
||||
```bash
|
||||
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
|
||||
pouch check my-skill
|
||||
pouch finalize my-skill
|
||||
pouch publish skills/my-skill -m "add my-skill" --push
|
||||
pouch add my-skill -a cursor -g -y
|
||||
pouch doctor -a cursor
|
||||
```
|
||||
|
||||
### 在项目中启用 skill
|
||||
|
||||
```bash
|
||||
cd ~/code/my-app
|
||||
pouch add declarative-openspec-loop -a cursor -y
|
||||
```
|
||||
|
||||
### 添加 Catalog Source
|
||||
|
||||
```bash
|
||||
pouch catalog add my-ext https://github.com/org/repo --ref main
|
||||
pouch fetch my-ext
|
||||
pouch add my-ext -g
|
||||
```
|
||||
|
||||
## 源码结构
|
||||
|
||||
```
|
||||
pouch/
|
||||
├── __init__.py # 版本号
|
||||
├── __main__.py # python3 -m pouch 入口
|
||||
├── cli.py # 命令定义与调度
|
||||
├── paths.py # 路径常量与 Agent 目标
|
||||
├── skills.py # builtin/catalog/custom 统一解析
|
||||
├── catalog.py # catalog.yaml 读写与 Skill 发现
|
||||
├── sources.py # custom source 配置、发现与 Git 管理
|
||||
├── project.py # .pouch.yaml 管理
|
||||
├── symlinks.py # 软链创建/检查/修复
|
||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||
```
|
||||
|
||||
入口脚本:[../bin/pouch](../bin/pouch)
|
||||
|
||||
## 路径约定
|
||||
|
||||
| 变量 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POUCH_HOME` | `~/.pouch` | pouch 仓库(软链;兼容 `~/.skills`) |
|
||||
| `SKILLS_DIR` | `~/.pouch/skills/` | builtin skill 目录 |
|
||||
| `CATALOG_FILE` | `~/.pouch/catalog.yaml` | 预置 Skill 来源目录 |
|
||||
| `CATALOG_CACHE_DIR` | `~/.local/share/pouch/externals/` | catalog checkout 缓存;按 repo/ref 共享 |
|
||||
| `CONFIG_FILE` | `~/.config/pouch/config.yaml` | custom source 配置 |
|
||||
| `SOURCES_DIR` | `~/.local/share/pouch/sources/` | custom Git source 默认 checkout |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
|
||||
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
|
||||
- 若 Agent 将软链替换为普通目录,运行 `pouch doctor --fix` 重建
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [项目 README](../README.md)
|
||||
- [AGENTS.md](../AGENTS.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""pouch — Agent Skills 安装与管理 CLI。"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from pouch.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Agent 名称解析(兼容 Vercel skills CLI 别名)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pouch.paths import ALL_TARGETS
|
||||
|
||||
AGENT_ALIASES: dict[str, str] = {
|
||||
"cursor": "cursor",
|
||||
"claude": "claude",
|
||||
"claude-code": "claude",
|
||||
"codex": "codex",
|
||||
"agents": "agents",
|
||||
}
|
||||
|
||||
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
||||
|
||||
|
||||
def flatten_agent_args(groups: list[list[str]] | None) -> list[str] | None:
|
||||
if not groups:
|
||||
return None
|
||||
flat = [item for group in groups for item in group]
|
||||
return flat or None
|
||||
|
||||
|
||||
def resolve_agent_args(agents: list[str] | None) -> list[str]:
|
||||
if not agents or agents == ["*"] or "*" in agents:
|
||||
return list(ALL_TARGETS)
|
||||
|
||||
resolved: list[str] = []
|
||||
for raw in agents:
|
||||
key = raw.lower()
|
||||
if key == "*":
|
||||
return list(ALL_TARGETS)
|
||||
target = AGENT_ALIASES.get(key)
|
||||
if target is None:
|
||||
choices = ", ".join(AGENT_CHOICES)
|
||||
raise SystemExit(f"未知 agent: {raw!r},可选: {choices}")
|
||||
if target != "*" and target not in resolved:
|
||||
resolved.append(target)
|
||||
return resolved
|
||||
@@ -0,0 +1,105 @@
|
||||
"""预置 Skill catalog 的配置、发现与本地 checkout 路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pouch import yaml_io
|
||||
from pouch.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
|
||||
|
||||
|
||||
def load_catalog(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
path = path or (
|
||||
CATALOG_FILE if CATALOG_FILE.is_file() else LEGACY_REGISTRY_FILE
|
||||
)
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"catalog 格式错误: {path}")
|
||||
return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")}
|
||||
|
||||
|
||||
def save_catalog(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||
path = path or CATALOG_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
|
||||
|
||||
def catalog_repo(entry: dict[str, Any]) -> str:
|
||||
"""Expand a local home-relative repo while leaving remote URLs unchanged."""
|
||||
repo = str(entry.get("repo", ""))
|
||||
return str(Path(repo).expanduser()) if repo.startswith("~") else repo
|
||||
|
||||
|
||||
def catalog_repo_path(entry: dict[str, Any]) -> Path:
|
||||
"""Return the shared checkout path for a repo/ref pair."""
|
||||
from pouch.paths import CATALOG_CACHE_DIR
|
||||
|
||||
repo = str(entry.get("repo", ""))
|
||||
ref = str(entry.get("ref", "main"))
|
||||
digest = hashlib.sha256(f"{repo}\0{ref}".encode()).hexdigest()[:16]
|
||||
return CATALOG_CACHE_DIR / "_repos" / digest
|
||||
|
||||
|
||||
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
|
||||
"""Use a local repo directly, otherwise return its external checkout."""
|
||||
from pouch.paths import CATALOG_CACHE_DIR
|
||||
|
||||
configured_repo = str(entry.get("repo", ""))
|
||||
local_repo = Path(catalog_repo(entry))
|
||||
if configured_repo.startswith("~") and local_repo.is_dir():
|
||||
return local_repo.resolve()
|
||||
|
||||
legacy = CATALOG_CACHE_DIR / name
|
||||
return legacy if legacy.is_dir() else catalog_repo_path(entry)
|
||||
|
||||
|
||||
def catalog_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
||||
entry = entry or load_catalog().get(name, {})
|
||||
subpath = entry.get("path", ".") or "."
|
||||
checkout = catalog_checkout_path(name, entry).resolve()
|
||||
skill_path = (checkout / subpath).resolve()
|
||||
try:
|
||||
skill_path.relative_to(checkout)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(
|
||||
f"catalog 条目 {name!r} 的 path 超出来源仓库: {subpath!r}"
|
||||
) from exc
|
||||
return skill_path
|
||||
|
||||
|
||||
def discover_catalog_skills(
|
||||
name: str,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> dict[str, Path]:
|
||||
"""Discover a catalog provider containing one or more skills."""
|
||||
entry = entry or load_catalog().get(name, {})
|
||||
root = catalog_skill_path(name, entry)
|
||||
if (root / "SKILL.md").is_file():
|
||||
return {name: root}
|
||||
if not root.is_dir():
|
||||
return {}
|
||||
resolved_root = root.resolve()
|
||||
skills: dict[str, Path] = {}
|
||||
for item in sorted(root.iterdir()):
|
||||
skill_md = item / "SKILL.md"
|
||||
if (
|
||||
item.name.startswith("_")
|
||||
or item.is_symlink()
|
||||
or not item.is_dir()
|
||||
or skill_md.is_symlink()
|
||||
or not skill_md.is_file()
|
||||
):
|
||||
continue
|
||||
resolved_item = item.resolve()
|
||||
resolved_skill_md = skill_md.resolve()
|
||||
try:
|
||||
resolved_item.relative_to(resolved_root)
|
||||
resolved_skill_md.relative_to(resolved_item)
|
||||
except ValueError:
|
||||
continue
|
||||
skills[item.name] = item
|
||||
return skills
|
||||
+1825
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
"""~/.pouch 仓库内的 git 操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pouch.paths import POUCH_HOME
|
||||
|
||||
|
||||
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=not sys.stdout.isatty(),
|
||||
)
|
||||
|
||||
|
||||
def ensure_git_repo(repo: Path) -> None:
|
||||
if not (repo / ".git").exists():
|
||||
raise SystemExit(f"不是 git 仓库: {repo}")
|
||||
|
||||
|
||||
def has_staged_changes(repo: Path) -> bool:
|
||||
result = _run_git(repo, "diff", "--cached", "--quiet", check=False)
|
||||
return result.returncode == 1
|
||||
|
||||
|
||||
def publish(
|
||||
*,
|
||||
paths: list[str],
|
||||
message: str | None,
|
||||
push: bool,
|
||||
no_commit: bool,
|
||||
) -> None:
|
||||
repo = POUCH_HOME.resolve()
|
||||
ensure_git_repo(repo)
|
||||
|
||||
_run_git(repo, "add", "--", *paths)
|
||||
|
||||
if not has_staged_changes(repo):
|
||||
print("没有可提交的变更", file=sys.stdout)
|
||||
return
|
||||
|
||||
if no_commit:
|
||||
print(f"已暂存变更: {', '.join(paths)}", file=sys.stdout)
|
||||
return
|
||||
|
||||
if not message:
|
||||
raise SystemExit("提交需要 -m/--message")
|
||||
|
||||
_run_git(repo, "commit", "-m", message)
|
||||
print(f"已提交: {message}", file=sys.stdout)
|
||||
|
||||
if push:
|
||||
_run_git(repo, "push")
|
||||
print("已推送到远程", file=sys.stdout)
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
"""路径与 Agent 目标定义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
PREFERRED_POUCH_HOME = HOME / ".pouch"
|
||||
LEGACY_POUCH_HOME = HOME / ".skills"
|
||||
PROJECT_MANIFEST = ".pouch.yaml"
|
||||
LEGACY_PROJECT_MANIFEST = ".skills.yaml"
|
||||
|
||||
|
||||
def _first_existing_dir(*candidates: Path, default: Path) -> Path:
|
||||
for path in candidates:
|
||||
if path.is_dir():
|
||||
return path
|
||||
return default
|
||||
|
||||
|
||||
def _first_existing_file(*candidates: Path, default: Path) -> Path:
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
return path
|
||||
return default
|
||||
|
||||
|
||||
POUCH_HOME = _first_existing_dir(
|
||||
PREFERRED_POUCH_HOME,
|
||||
LEGACY_POUCH_HOME,
|
||||
default=PREFERRED_POUCH_HOME,
|
||||
)
|
||||
SKILLS_DIR = POUCH_HOME / "skills"
|
||||
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||
DRAFTS_DIR = POUCH_HOME / ".drafts"
|
||||
CATALOG_FILE = POUCH_HOME / "catalog.yaml"
|
||||
LEGACY_REGISTRY_FILE = POUCH_HOME / "registry.yaml"
|
||||
CATALOG_CACHE_DIR = _first_existing_dir(
|
||||
HOME / ".local" / "share" / "pouch" / "externals",
|
||||
HOME / ".local" / "share" / "skills" / "externals",
|
||||
default=HOME / ".local" / "share" / "pouch" / "externals",
|
||||
)
|
||||
CONFIG_FILE = _first_existing_file(
|
||||
HOME / ".config" / "pouch" / "config.yaml",
|
||||
HOME / ".config" / "skiff" / "config.yaml",
|
||||
default=HOME / ".config" / "pouch" / "config.yaml",
|
||||
)
|
||||
SOURCES_DIR = _first_existing_dir(
|
||||
HOME / ".local" / "share" / "pouch" / "sources",
|
||||
HOME / ".local" / "share" / "skiff" / "sources",
|
||||
default=HOME / ".local" / "share" / "pouch" / "sources",
|
||||
)
|
||||
|
||||
AGENT_GLOBAL: dict[str, Path] = {
|
||||
"cursor": HOME / ".cursor" / "skills",
|
||||
"claude": HOME / ".claude" / "skills",
|
||||
"codex": HOME / ".codex" / "skills",
|
||||
# agents 标准目录:OMP 原生 canonical(agents provider),cursor/codex 项目级同路径
|
||||
"agents": HOME / ".agents" / "skills",
|
||||
}
|
||||
|
||||
AGENT_PROJECT: dict[str, str] = {
|
||||
"cursor": ".agents/skills",
|
||||
"claude": ".claude/skills",
|
||||
"codex": ".agents/skills",
|
||||
# 与 cursor/codex 共用 .agents/skills;软链幂等,同路径只写一次
|
||||
"agents": ".agents/skills",
|
||||
}
|
||||
|
||||
ALL_TARGETS = ("cursor", "claude", "codex", "agents")
|
||||
|
||||
|
||||
def project_manifest(root: Path) -> Path:
|
||||
"""项目清单路径:优先 .pouch.yaml,否则沿用 .skills.yaml。"""
|
||||
preferred = root / PROJECT_MANIFEST
|
||||
legacy = root / LEGACY_PROJECT_MANIFEST
|
||||
if preferred.is_file():
|
||||
return preferred
|
||||
if legacy.is_file():
|
||||
return legacy
|
||||
return preferred
|
||||
|
||||
|
||||
def resolve_targets(target: str | None) -> list[str]:
|
||||
if target is None or target == "all":
|
||||
return list(ALL_TARGETS)
|
||||
if target not in ALL_TARGETS:
|
||||
raise ValueError(f"未知 target: {target!r},可选: {', '.join(ALL_TARGETS)}, all")
|
||||
return [target]
|
||||
|
||||
|
||||
def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
|
||||
if project_root is None:
|
||||
return AGENT_GLOBAL[target]
|
||||
return project_root / AGENT_PROJECT[target]
|
||||
|
||||
|
||||
def ensure_pouch_home() -> None:
|
||||
if not POUCH_HOME.is_dir():
|
||||
raise SystemExit(
|
||||
"~/.pouch 不存在。请将本仓库克隆到 ~/.pouch。"
|
||||
"若仍是 ~/.skills,先执行: mv ~/.skills ~/.pouch"
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""项目级 .pouch.yaml 管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pouch import yaml_io
|
||||
from pouch.paths import project_manifest
|
||||
from pouch.skills import normalize_source, resolve_skill_source
|
||||
|
||||
|
||||
def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]:
|
||||
path = path or project_manifest(Path.cwd())
|
||||
if not path.is_file():
|
||||
return path, {"skills": []}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f".pouch.yaml 格式错误: {path}")
|
||||
if "skills" not in data:
|
||||
data["skills"] = []
|
||||
return path, data
|
||||
|
||||
|
||||
def save_manifest(path: Path, data: dict[str, Any]) -> None:
|
||||
path.write_text(
|
||||
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(entry, str):
|
||||
return {"name": entry, "source": "builtin"}
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
raise SystemExit(f".pouch.yaml 条目缺少 name: {entry}")
|
||||
source = normalize_source(str(entry.get("source", "builtin")))
|
||||
if source == "catalog" and entry.get("registry"):
|
||||
source = f"catalog:{entry['registry']}"
|
||||
return {"name": name, "source": source, **{k: v for k, v in entry.items() if k not in ("name", "source")}}
|
||||
|
||||
|
||||
def manifest_skill_names(data: dict[str, Any]) -> list[str]:
|
||||
return [normalize_skill_entry(e)["name"] for e in data.get("skills", [])]
|
||||
|
||||
|
||||
def _entry_to_yaml(entry: dict[str, Any]) -> str | dict[str, Any]:
|
||||
return entry
|
||||
|
||||
|
||||
def add_skill_to_manifest(
|
||||
manifest_path: Path,
|
||||
name: str,
|
||||
*,
|
||||
source: str = "builtin",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
path = manifest_path
|
||||
if path.is_dir():
|
||||
path = project_manifest(path)
|
||||
|
||||
file_path, data = load_manifest(path) if path.is_file() else (path, {"skills": []})
|
||||
if not path.is_file():
|
||||
file_path = path
|
||||
|
||||
entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
|
||||
item: dict[str, Any] = {"name": name, "source": source}
|
||||
if extra:
|
||||
item.update(extra)
|
||||
existing_index = next((i for i, entry in enumerate(entries) if entry["name"] == name), None)
|
||||
if existing_index is None:
|
||||
entries.append(item)
|
||||
elif entries[existing_index] == item:
|
||||
return
|
||||
else:
|
||||
entries[existing_index] = item
|
||||
|
||||
data["skills"] = [_entry_to_yaml(e) for e in entries]
|
||||
save_manifest(file_path, data)
|
||||
|
||||
|
||||
def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool:
|
||||
path = manifest_path
|
||||
if path.is_dir() or not path.is_file():
|
||||
path = project_manifest(path if path.is_dir() else path.parent)
|
||||
file_path, data = load_manifest(path)
|
||||
original = data.get("skills", [])
|
||||
kept = [e for e in original if normalize_skill_entry(e)["name"] != name]
|
||||
if len(kept) == len(original):
|
||||
return False
|
||||
data["skills"] = kept
|
||||
save_manifest(file_path, data)
|
||||
return True
|
||||
|
||||
|
||||
def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [normalize_skill_entry(e) for e in data.get("skills", [])]
|
||||
|
||||
|
||||
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
|
||||
name = entry["name"]
|
||||
source = normalize_source(entry.get("source", "builtin"))
|
||||
if source == "catalog" and entry.get("registry"):
|
||||
source = f"catalog:{entry['registry']}"
|
||||
return resolve_skill_source(name, source=source)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Terminal multi-select UI for skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillChoice:
|
||||
name: str
|
||||
kind: str
|
||||
description: str = ""
|
||||
installed: bool = False
|
||||
readonly_status: str = ""
|
||||
children: tuple[str, ...] = ()
|
||||
indent: int = 0
|
||||
|
||||
|
||||
def fit_to_width(text: str, width: int) -> str:
|
||||
"""Trim text to terminal display width without splitting wide characters."""
|
||||
if width <= 0:
|
||||
return ""
|
||||
result: list[str] = []
|
||||
used = 0
|
||||
for char in text:
|
||||
if unicodedata.combining(char):
|
||||
char_width = 0
|
||||
else:
|
||||
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
|
||||
if used + char_width > width:
|
||||
break
|
||||
result.append(char)
|
||||
used += char_width
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
return sum(
|
||||
0
|
||||
if unicodedata.combining(char)
|
||||
else 2
|
||||
if unicodedata.east_asian_width(char) in {"W", "F"}
|
||||
else 1
|
||||
for char in text
|
||||
)
|
||||
|
||||
|
||||
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
|
||||
needle = query.casefold().strip()
|
||||
if not needle:
|
||||
return list(choices)
|
||||
return [
|
||||
choice
|
||||
for choice in choices
|
||||
if needle in choice.name.casefold()
|
||||
or needle in choice.kind.casefold()
|
||||
or needle in choice.description.casefold()
|
||||
or needle in choice.readonly_status.casefold()
|
||||
]
|
||||
|
||||
|
||||
def select_skills(
|
||||
choices: list[SkillChoice],
|
||||
*,
|
||||
scope_label: str = "当前作用域",
|
||||
wrapper: Callable[..., set[str] | None] | None = None,
|
||||
) -> set[str] | None:
|
||||
"""Open the selector. Return names, or None when cancelled."""
|
||||
if not choices:
|
||||
return set()
|
||||
|
||||
try:
|
||||
import curses
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
|
||||
|
||||
selected = {
|
||||
choice.name
|
||||
for choice in choices
|
||||
if choice.installed and not choice.children
|
||||
}
|
||||
|
||||
def mark_for(choice: SkillChoice) -> str:
|
||||
if not choice.children:
|
||||
return "x" if choice.name in selected else " "
|
||||
selected_children = selected.intersection(choice.children)
|
||||
if len(selected_children) == len(choice.children):
|
||||
return "x"
|
||||
return "-" if selected_children else " "
|
||||
|
||||
def draw(stdscr: object) -> set[str] | None:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
current = 0
|
||||
query = ""
|
||||
source_attr = curses.A_NORMAL
|
||||
status_attr = curses.A_NORMAL
|
||||
description_attr = curses.A_DIM
|
||||
try:
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||
source_attr = curses.color_pair(1)
|
||||
status_attr = curses.color_pair(2)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
while True:
|
||||
visible = filter_choices(choices, query)
|
||||
current = min(current, max(0, len(visible) - 1))
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
text_width = max(0, width - 1)
|
||||
stdscr.addnstr(
|
||||
0,
|
||||
0,
|
||||
fit_to_width(f"安装目标: {scope_label}", text_width),
|
||||
text_width,
|
||||
)
|
||||
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
|
||||
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
|
||||
if query:
|
||||
stdscr.addnstr(
|
||||
2,
|
||||
0,
|
||||
fit_to_width(f"搜索: {query}", text_width),
|
||||
text_width,
|
||||
)
|
||||
|
||||
item_capacity = max(1, (height - 4) // 3)
|
||||
start = max(0, current - item_capacity + 1)
|
||||
for offset, choice in enumerate(visible[start : start + item_capacity]):
|
||||
index = start + offset
|
||||
row = 3 + offset * 3
|
||||
mark = mark_for(choice)
|
||||
description = f" {choice.description}" if choice.description else ""
|
||||
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
|
||||
parts = [
|
||||
(f"{' ' * choice.indent}[{mark}] ", focus_attr),
|
||||
(choice.name, focus_attr | curses.A_BOLD),
|
||||
(f" {choice.kind}", focus_attr | source_attr),
|
||||
]
|
||||
if choice.readonly_status:
|
||||
parts.append(
|
||||
(f" {choice.readonly_status}", focus_attr | status_attr)
|
||||
)
|
||||
column = 0
|
||||
for text, attr in parts:
|
||||
available = text_width - column
|
||||
if available <= 0:
|
||||
break
|
||||
fitted = fit_to_width(text, available)
|
||||
stdscr.addnstr(row, column, fitted, available, attr)
|
||||
column += display_width(fitted)
|
||||
stdscr.addnstr(
|
||||
row + 1,
|
||||
0,
|
||||
fit_to_width(description, text_width),
|
||||
text_width,
|
||||
focus_attr | description_attr,
|
||||
)
|
||||
|
||||
footer = (
|
||||
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
|
||||
)
|
||||
stdscr.addnstr(
|
||||
height - 1,
|
||||
0,
|
||||
fit_to_width(footer, text_width),
|
||||
text_width,
|
||||
)
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
|
||||
if key in ("q", "Q", "\x1b"):
|
||||
return None
|
||||
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||
return set(selected)
|
||||
if key == curses.KEY_UP and visible:
|
||||
current = (current - 1) % len(visible)
|
||||
elif key == curses.KEY_DOWN and visible:
|
||||
current = (current + 1) % len(visible)
|
||||
elif key == " " and visible:
|
||||
choice = visible[current]
|
||||
if choice.children:
|
||||
children = set(choice.children)
|
||||
if children.issubset(selected):
|
||||
selected.difference_update(children)
|
||||
else:
|
||||
selected.update(children)
|
||||
else:
|
||||
selected.symmetric_difference_update({choice.name})
|
||||
elif key == "/":
|
||||
curses.curs_set(1)
|
||||
query = _read_query(stdscr, curses, width, row=2)
|
||||
curses.curs_set(0)
|
||||
current = 0
|
||||
|
||||
runner = wrapper or curses.wrapper
|
||||
return runner(draw)
|
||||
|
||||
|
||||
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
|
||||
query = ""
|
||||
while True:
|
||||
stdscr.move(row, 0)
|
||||
stdscr.clrtoeol()
|
||||
text_width = max(0, width - 1)
|
||||
stdscr.addnstr(
|
||||
row,
|
||||
0,
|
||||
fit_to_width(f"搜索: {query}", text_width),
|
||||
text_width,
|
||||
)
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||
return query
|
||||
if key == "\x1b":
|
||||
return ""
|
||||
if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE:
|
||||
query = query[:-1]
|
||||
elif isinstance(key, str) and key.isprintable():
|
||||
query += key
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
"""自研 skill 发现与解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pouch.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_pouch_home
|
||||
from pouch.catalog import (
|
||||
catalog_skill_path,
|
||||
discover_catalog_skills,
|
||||
load_catalog,
|
||||
)
|
||||
from pouch.sources import (
|
||||
discover_source_skills,
|
||||
list_source_skills,
|
||||
load_sources,
|
||||
)
|
||||
|
||||
|
||||
def list_builtin_skills() -> list[str]:
|
||||
ensure_pouch_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 builtin_skill_path(name: str) -> Path:
|
||||
path = SKILLS_DIR / name
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(f"builtin skill 不存在: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def normalize_source(source: str | None) -> str | None:
|
||||
if source == "owned":
|
||||
return "builtin"
|
||||
if source == "registry":
|
||||
return "catalog"
|
||||
if source and source.startswith("registry:"):
|
||||
return f"catalog:{source.split(':', 1)[1]}"
|
||||
return source
|
||||
|
||||
|
||||
def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]:
|
||||
if "/" not in spec:
|
||||
return spec, normalize_source(source)
|
||||
qualified_source, name = spec.split("/", 1)
|
||||
if not qualified_source or not name or "/" in name:
|
||||
raise SystemExit(f"skill 限定名称无效: {spec!r}(应为 source/name)")
|
||||
qualified_source = normalize_source(qualified_source)
|
||||
source = normalize_source(source)
|
||||
if source and source != qualified_source:
|
||||
raise SystemExit(
|
||||
f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致"
|
||||
)
|
||||
return name, qualified_source
|
||||
|
||||
|
||||
def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
|
||||
sources = load_sources()
|
||||
if source:
|
||||
if source not in sources:
|
||||
raise SystemExit(f"未配置 source: {source}")
|
||||
return {source: list_source_skills(source, sources[source])}
|
||||
return {name: list_source_skills(name, entry) for name, entry in sources.items()}
|
||||
|
||||
|
||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
||||
ensure_pouch_home()
|
||||
name, source = split_skill_spec(name, source)
|
||||
|
||||
builtin = SKILLS_DIR / name
|
||||
if source == "builtin":
|
||||
if not (builtin / "SKILL.md").is_file():
|
||||
raise SystemExit(f"builtin source 中找不到 skill: {name}")
|
||||
return builtin, "builtin"
|
||||
|
||||
catalog = load_catalog()
|
||||
sources = load_sources()
|
||||
if source and source.startswith("catalog:"):
|
||||
provider = source.split(":", 1)[1]
|
||||
if provider not in catalog:
|
||||
raise SystemExit(f"catalog 中找不到 source: {provider}")
|
||||
skills = discover_catalog_skills(provider, catalog[provider])
|
||||
if name not in skills:
|
||||
available = ", ".join(skills) or "(无)"
|
||||
raise SystemExit(
|
||||
f"catalog source {provider!r} 中找不到 skill {name!r}。可用: {available}"
|
||||
)
|
||||
return skills[name], f"catalog:{provider}"
|
||||
|
||||
if source == "catalog":
|
||||
if name not in catalog:
|
||||
raise SystemExit(f"catalog 中找不到 skill source: {name}")
|
||||
path = catalog_skill_path(name, catalog[name])
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(
|
||||
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
|
||||
f"请运行: pouch fetch {name}"
|
||||
)
|
||||
return path, f"catalog:{name}"
|
||||
|
||||
if source:
|
||||
if source not in sources:
|
||||
raise SystemExit(
|
||||
f"项目依赖 source {source!r},但本机尚未配置。"
|
||||
f"请运行: pouch source add {source} <repo>"
|
||||
)
|
||||
skills = discover_source_skills(source, sources[source])
|
||||
if name not in skills:
|
||||
raise SystemExit(f"source {source!r} 中找不到 skill: {name}")
|
||||
return skills[name], source
|
||||
|
||||
candidates: list[tuple[Path, str]] = []
|
||||
if (builtin / "SKILL.md").is_file():
|
||||
candidates.append((builtin, "builtin"))
|
||||
if name in catalog:
|
||||
path = catalog_skill_path(name, catalog[name])
|
||||
if (path / "SKILL.md").is_file() or not path.exists():
|
||||
candidates.append((path, f"catalog:{name}"))
|
||||
for provider, entry in catalog.items():
|
||||
if provider == name:
|
||||
continue
|
||||
skills = discover_catalog_skills(provider, entry)
|
||||
if name in skills:
|
||||
candidates.append((skills[name], f"catalog:{provider}"))
|
||||
for source_name, entry in sources.items():
|
||||
skills = discover_source_skills(source_name, entry)
|
||||
if name in skills:
|
||||
candidates.append((skills[name], source_name))
|
||||
|
||||
unique_candidates: list[tuple[Path, str]] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for path, candidate_source in candidates:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen_paths:
|
||||
continue
|
||||
seen_paths.add(resolved)
|
||||
unique_candidates.append((path, candidate_source))
|
||||
candidates = unique_candidates
|
||||
|
||||
if len(candidates) > 1:
|
||||
choices = ", ".join(f"{candidate_source}/{name}" for _, candidate_source in candidates)
|
||||
raise SystemExit(f"skill 名称存在多个来源,请明确指定: {choices}")
|
||||
if candidates:
|
||||
path, resolved_source = candidates[0]
|
||||
if resolved_source.startswith("catalog:") and not path.exists():
|
||||
provider = resolved_source.split(":", 1)[1]
|
||||
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: pouch fetch {provider}")
|
||||
return path, resolved_source
|
||||
|
||||
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")
|
||||
|
||||
|
||||
_PLACEHOLDER_PATTERNS = (
|
||||
(r"^name:\s*skill-name\s*$", "skill-name"),
|
||||
(r"^#\s+Skill 名称\s*$", "Skill 名称"),
|
||||
(r"简要描述 skill 做什么、何时触发", "简要描述 skill 做什么"),
|
||||
(r"^-\s*触发场景 1\s*$", "触发场景 1"),
|
||||
(r"^\d+\.\s*第一步\s*$", "第一步"),
|
||||
)
|
||||
|
||||
|
||||
def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]:
|
||||
"""返回 skill 目录中的校验问题;空列表表示通过。"""
|
||||
issues: list[str] = []
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
readme = skill_dir / "README.md"
|
||||
if not skill_md.is_file():
|
||||
return ["缺少 SKILL.md"]
|
||||
if not readme.is_file():
|
||||
issues.append("缺少 README.md(面向人类的使用说明)")
|
||||
else:
|
||||
readme_text = readme.read_text(encoding="utf-8")
|
||||
if not readme_text.strip():
|
||||
issues.append("README.md 为空")
|
||||
if re.search(r"^#\s+skill-name\s*$", readme_text, re.MULTILINE):
|
||||
issues.append("README.md 存在模板占位内容: skill-name")
|
||||
for placeholder in (
|
||||
"用一句话告诉使用者这个 skill 能解决什么问题",
|
||||
"给出用户可以直接说出的典型请求",
|
||||
"给出一条可以直接交给 Agent 的示例请求",
|
||||
):
|
||||
if placeholder in readme_text:
|
||||
issues.append(f"README.md 存在模板占位内容: {placeholder}")
|
||||
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
if not text.strip():
|
||||
return ["SKILL.md 为空"]
|
||||
if not text.startswith("---\n"):
|
||||
return ["SKILL.md 缺少 YAML frontmatter"]
|
||||
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
return ["SKILL.md frontmatter 未闭合"]
|
||||
frontmatter = text[4:end]
|
||||
keys = re.findall(r"^([A-Za-z0-9_-]+):", frontmatter, re.MULTILINE)
|
||||
unexpected = sorted(set(keys) - {"name", "description"})
|
||||
missing = sorted({"name", "description"} - set(keys))
|
||||
if missing:
|
||||
issues.append(f"frontmatter 缺少字段: {', '.join(missing)}")
|
||||
if unexpected:
|
||||
issues.append(f"frontmatter 只允许 name、description,发现: {', '.join(unexpected)}")
|
||||
|
||||
meta = read_skill_meta(skill_dir)
|
||||
if meta.get("name") != expected_name:
|
||||
issues.append(
|
||||
f"目录名与 frontmatter name 不一致: {expected_name} != {meta.get('name', '(缺失)')}"
|
||||
)
|
||||
description = meta.get("description", "").strip()
|
||||
if not description:
|
||||
issues.append("description 不能为空")
|
||||
|
||||
found_placeholders = [
|
||||
label
|
||||
for pattern, label in _PLACEHOLDER_PATTERNS
|
||||
if re.search(pattern, text, re.MULTILINE | re.IGNORECASE)
|
||||
]
|
||||
if found_placeholders:
|
||||
issues.append(f"存在模板占位内容: {', '.join(found_placeholders)}")
|
||||
|
||||
link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
||||
for target in link_pattern.findall(text):
|
||||
target = target.strip().split("#", 1)[0]
|
||||
if not target or "://" in target or target.startswith(("mailto:", "/")):
|
||||
continue
|
||||
if not (skill_dir / target).resolve().is_file():
|
||||
issues.append(f"引用文件不存在: {target}")
|
||||
|
||||
for draft_file in ("brief.yaml",):
|
||||
if (skill_dir / draft_file).exists():
|
||||
issues.append(f"正式 skill 不应包含草稿文件: {draft_file}")
|
||||
return issues
|
||||
@@ -0,0 +1,217 @@
|
||||
# Skill 来源模型
|
||||
|
||||
## 背景
|
||||
|
||||
pouch 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
|
||||
`registry` 外部仓库和用户配置的 custom source。
|
||||
|
||||
这些名称混合了不同维度:
|
||||
|
||||
- `owned` 表示所有权。
|
||||
- `registry` 表示来源配置存放在哪里。
|
||||
- `external` 表示内容不在当前仓库。
|
||||
- `collection` 表示一个来源中包含多个 Skill。
|
||||
|
||||
它们不能作为同一层级的互斥类型。例如 custom source 既可能来自外部 Git 仓库,
|
||||
也可能是 collection;因此 `custom` 与 `external` 并不互斥。
|
||||
|
||||
本文建议将用户可见的来源注册方式统一为三类:
|
||||
|
||||
- `builtin`:随当前 pouch 仓库提供的 Skill。
|
||||
- `catalog:<name>`:由 pouch 自带目录预先登记的来源。
|
||||
- `custom:<name>`:用户在本机显式配置的命名来源。
|
||||
|
||||
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
|
||||
来源都可以包含一个或多个 Skill;除 builtin 外,catalog 和 custom 都可以使用
|
||||
Git 仓库或本地目录。
|
||||
|
||||
## 迁移前模型
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S[pouch 可发现的 Skills]
|
||||
|
||||
S --> O["owned<br/>~/.pouch/skills/*"]
|
||||
S --> C["custom source<br/>~/.config/pouch/config.yaml"]
|
||||
S --> R["registry<br/>registry.yaml"]
|
||||
|
||||
O --> O1["本仓库维护<br/>随 pouch 一起分发"]
|
||||
|
||||
C --> C1["本地目录<br/>--local PATH"]
|
||||
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
|
||||
|
||||
R --> R1["外部单 Skill 仓库"]
|
||||
R --> R2["外部 Skill Collection"]
|
||||
R1 --> E["~/.local/share/pouch/externals/"]
|
||||
R2 --> E
|
||||
```
|
||||
|
||||
迁移前实现中:
|
||||
|
||||
- `list` 和 `status` 支持 owned、registry 和 custom source。
|
||||
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
|
||||
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
|
||||
- `.pouch.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
|
||||
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
|
||||
|
||||
## 现行模型
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Skill Provider]
|
||||
|
||||
A --> B["builtin<br/>仓库内隐式注册"]
|
||||
A --> C["catalog:waza<br/>pouch 预置目录"]
|
||||
A --> D["custom:company<br/>用户本机配置"]
|
||||
|
||||
B --> B1["skills/ack"]
|
||||
B --> B2["skills/pouch"]
|
||||
B --> B3["skills/builder"]
|
||||
|
||||
C --> C1["Git 或本地目录"]
|
||||
C1 --> C2["单 Skill"]
|
||||
C1 --> C3["Collection"]
|
||||
C3 --> C4["waza/think"]
|
||||
C3 --> C5["waza/check"]
|
||||
|
||||
D --> D1["Git 或本地目录"]
|
||||
D1 --> D2["单 Skill"]
|
||||
D1 --> D3["Collection"]
|
||||
D3 --> D4["company/security-review"]
|
||||
D3 --> D5["company/release"]
|
||||
```
|
||||
|
||||
### 来源注册方式
|
||||
|
||||
| 类型 | 含义 | 配置来源 | 用户界面展示 |
|
||||
| --- | --- | --- | --- |
|
||||
| `builtin` | 随当前 pouch 仓库提供 | `skills/` | `builtin` |
|
||||
| `catalog` | pouch 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
|
||||
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/pouch/config.yaml` | `custom:<name>` |
|
||||
|
||||
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
|
||||
方式,而不是仓库的所有权关系。
|
||||
|
||||
`catalog` 比 `registry` 更准确:当前文件只是 pouch 随仓库维护的预置来源目录,
|
||||
并不是远程注册中心,也不是一种 Skill 来源协议。
|
||||
|
||||
### 三个正交维度
|
||||
|
||||
每个 provider 应由三个维度描述:
|
||||
|
||||
```text
|
||||
registration: builtin | catalog | custom
|
||||
transport: bundled | local | git
|
||||
layout: single | collection
|
||||
```
|
||||
|
||||
custom source 示例:
|
||||
|
||||
```text
|
||||
registration: custom
|
||||
name: company
|
||||
transport: local | git
|
||||
layout: single | collection
|
||||
```
|
||||
|
||||
catalog entry 示例:
|
||||
|
||||
```text
|
||||
registration: catalog
|
||||
name: waza
|
||||
transport: local | git
|
||||
layout: single | collection
|
||||
```
|
||||
|
||||
`external` 只适合描述物理位置,可作为 catalog 和部分 custom provider 的统称,
|
||||
不应成为与 builtin、custom 并列的持久化类型。
|
||||
|
||||
## `select` 展示
|
||||
|
||||
`pouch select` 应同时展示 builtin、catalog 和 custom Skill:
|
||||
|
||||
```text
|
||||
[ ] ack builtin
|
||||
[ ] builder builtin
|
||||
[-] company custom source
|
||||
[ ] company/release custom:company
|
||||
[ ] company/security-review custom:company
|
||||
[-] waza catalog source
|
||||
[ ] waza/check catalog:waza
|
||||
[ ] waza/think catalog:waza
|
||||
```
|
||||
|
||||
建议遵循以下规则:
|
||||
|
||||
- builtin Skill 直接显示 Skill 名称。
|
||||
- catalog 和 custom collection 都使用来源名称作为父节点。
|
||||
- catalog 和 custom Skill 都使用 `<source>/<skill>` 作为选择器键。
|
||||
- 单 Skill 来源可以省略父节点,但仍显示注册方式和来源名称。
|
||||
- 不同来源安装后名称相同时,继续拒绝同时选择,避免覆盖同一安装路径。
|
||||
|
||||
## 配置表示
|
||||
|
||||
新写入的 `.pouch.yaml` 使用以下形式:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: ack
|
||||
source: builtin
|
||||
- name: security-review
|
||||
source: company
|
||||
- name: think
|
||||
source: catalog:waza
|
||||
```
|
||||
|
||||
custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以
|
||||
独立配置仓库地址,而项目只依赖稳定的来源名称。
|
||||
|
||||
## 兼容策略
|
||||
|
||||
用户可见术语已经调整,并保留以下读取兼容,避免已有项目立即失效:
|
||||
|
||||
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
|
||||
`custom:<name>`。
|
||||
2. 新生成的 `.pouch.yaml` 对内置 Skill 写入 `source: builtin`。
|
||||
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
|
||||
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
|
||||
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
|
||||
`catalog:<name>`。
|
||||
6. 主文件使用 `catalog.yaml`;不存在时兼容读取旧 `registry.yaml`。
|
||||
7. custom source 的逻辑名称和现有 `config.yaml` 结构保持不变。
|
||||
8. 将 `builtin`、`catalog` 和兼容别名 `owned`、`registry` 设为 custom source
|
||||
保留字。
|
||||
9. `select` 同步接入 custom Skill,并让 custom collection 与 catalog collection
|
||||
使用相同的父子展示逻辑。
|
||||
|
||||
## 实现范围
|
||||
|
||||
当前实现覆盖:
|
||||
|
||||
- `pouch/skills.py`:来源解析、归一化和 builtin 命名。
|
||||
- `pouch/project.py`:manifest 默认值、序列化与旧值兼容。
|
||||
- `pouch/sources.py`:来源保留字。
|
||||
- `pouch/catalog.py`:catalog 配置、checkout 与 Skill 发现。
|
||||
- `pouch/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
|
||||
- `pouch/selector.py`:统一 catalog/custom collection 的父子展示。
|
||||
- CLI 与来源解析测试。
|
||||
- 根 README、`pouch/README.md`、`skills/pouch/SKILL.md` 和相关示例。
|
||||
|
||||
## 验证要求
|
||||
|
||||
实施完成后至少覆盖:
|
||||
|
||||
- builtin Skill 可以通过新名称列出、选择、安装和写入 manifest。
|
||||
- 旧的 `source: owned` manifest 仍可同步。
|
||||
- custom local source 和 custom Git source 都出现在 selector 中。
|
||||
- catalog 单 Skill 和 Collection 都保持可选择。
|
||||
- custom 单 Skill 和 Collection 都保持可选择。
|
||||
- 多来源同名 Skill 必须显式指定来源。
|
||||
- 两个选择最终安装为同一个名称时,selector 拒绝冲突。
|
||||
- `list`、`status`、`select` 对同一来源使用一致术语。
|
||||
|
||||
## 设计前提
|
||||
|
||||
迁移前 `registry.yaml` 的真实职责只是维护 pouch 预置的来源目录,而不是提供远程
|
||||
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
|
||||
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
|
||||
@@ -0,0 +1,124 @@
|
||||
"""命名 custom skill source 的配置、发现与 Git 管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pouch import yaml_io
|
||||
from pouch.paths import CONFIG_FILE, SOURCES_DIR
|
||||
|
||||
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
|
||||
|
||||
|
||||
def validate_source_name(name: str) -> None:
|
||||
from pouch.skills import validate_skill_name
|
||||
|
||||
validate_skill_name(name)
|
||||
if name in RESERVED_SOURCES:
|
||||
raise SystemExit(f"source 名称为保留字: {name}")
|
||||
|
||||
|
||||
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
path = path or CONFIG_FILE
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"pouch 配置格式错误: {path}")
|
||||
raw = data.get("sources", {})
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise SystemExit(f"pouch 配置 sources 格式错误: {path}")
|
||||
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||
path = path or CONFIG_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
|
||||
local_path = entry.get("local_path")
|
||||
if local_path:
|
||||
return Path(str(local_path)).expanduser().resolve()
|
||||
checkout = entry.get("checkout")
|
||||
if checkout:
|
||||
return Path(str(checkout)).expanduser().resolve()
|
||||
return (SOURCES_DIR / name).resolve()
|
||||
|
||||
|
||||
def source_skills_root(name: str, entry: dict[str, Any]) -> Path:
|
||||
checkout = source_checkout(name, entry)
|
||||
raw_subpath = str(entry.get("skills_path", "skills") or "skills")
|
||||
subpath = Path(raw_subpath)
|
||||
if subpath.is_absolute():
|
||||
raise SystemExit(f"source {name!r} 的 skills_path 必须是仓库内相对路径")
|
||||
root = (checkout / subpath).resolve()
|
||||
try:
|
||||
root.relative_to(checkout)
|
||||
except ValueError:
|
||||
raise SystemExit(f"source {name!r} 的 skills_path 不能超出仓库目录") from None
|
||||
return root
|
||||
|
||||
|
||||
def discover_source_skills(name: str, entry: dict[str, Any]) -> dict[str, Path]:
|
||||
root = source_skills_root(name, entry)
|
||||
if (root / "SKILL.md").is_file():
|
||||
return {name: root}
|
||||
if not root.is_dir():
|
||||
return {}
|
||||
return {
|
||||
item.name: item
|
||||
for item in sorted(root.iterdir())
|
||||
if item.is_dir()
|
||||
and not item.name.startswith("_")
|
||||
and (item / "SKILL.md").is_file()
|
||||
}
|
||||
|
||||
|
||||
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
|
||||
return list(discover_source_skills(name, entry))
|
||||
|
||||
|
||||
def fetch_source(name: str, entry: dict[str, Any]) -> Path:
|
||||
if entry.get("local_path"):
|
||||
checkout = source_checkout(name, entry)
|
||||
if not checkout.is_dir():
|
||||
raise SystemExit(f"source {name!r} 的本地仓库不存在: {checkout}")
|
||||
return checkout
|
||||
|
||||
repo = entry.get("repo")
|
||||
if not repo:
|
||||
raise SystemExit(f"source {name!r} 缺少 repo 或 local_path")
|
||||
checkout = source_checkout(name, entry)
|
||||
ref = str(entry.get("ref", "main") or "main")
|
||||
if checkout.exists():
|
||||
if not (checkout / ".git").exists():
|
||||
raise SystemExit(f"source checkout 已存在但不是 Git 仓库: {checkout}")
|
||||
subprocess.run(["git", "-C", str(checkout), "fetch", "--all", "--tags"], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "checkout", ref], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "pull", "--ff-only"], check=True)
|
||||
else:
|
||||
checkout.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
ref,
|
||||
"--",
|
||||
str(repo),
|
||||
str(checkout),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return checkout
|
||||
@@ -0,0 +1,76 @@
|
||||
"""软链创建、移除与健康检查。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkStatus:
|
||||
link: Path
|
||||
expected: Path
|
||||
ok: bool
|
||||
issue: str | None = None
|
||||
|
||||
|
||||
def create_link(link: Path, target: Path) -> None:
|
||||
target = target.resolve()
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"目标不存在: {target}")
|
||||
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if link.is_symlink():
|
||||
current = link.resolve()
|
||||
if current == target:
|
||||
return
|
||||
link.unlink()
|
||||
elif link.exists():
|
||||
raise FileExistsError(f"已存在非软链路径: {link}")
|
||||
|
||||
link.symlink_to(target, target_is_directory=target.is_dir())
|
||||
|
||||
|
||||
def remove_link(link: Path) -> bool:
|
||||
if link.is_symlink():
|
||||
link.unlink()
|
||||
return True
|
||||
if link.exists():
|
||||
raise FileExistsError(f"不是软链,未删除: {link}")
|
||||
return False
|
||||
|
||||
|
||||
def check_link(link: Path, expected: Path) -> LinkStatus:
|
||||
expected = expected.resolve()
|
||||
if not link.exists() and not link.is_symlink():
|
||||
return LinkStatus(link, expected, False, "缺失")
|
||||
if not link.is_symlink():
|
||||
return LinkStatus(link, expected, False, "非软链(可能被 Agent 替换为普通目录)")
|
||||
actual = link.resolve()
|
||||
if actual != expected:
|
||||
return LinkStatus(link, expected, False, f"指向错误: {actual}")
|
||||
if not actual.exists():
|
||||
return LinkStatus(link, expected, False, "目标不存在")
|
||||
return LinkStatus(link, expected, True)
|
||||
|
||||
|
||||
def copy_template(src: Path, dst: Path) -> None:
|
||||
if dst.exists():
|
||||
raise FileExistsError(f"已存在: {dst}")
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
|
||||
def find_repo_root(start: Path | None = None) -> Path | None:
|
||||
start = (start or Path.cwd()).resolve()
|
||||
for directory in [start, *start.parents]:
|
||||
if (directory / ".pouch.yaml").is_file() or (directory / ".skills.yaml").is_file():
|
||||
return directory
|
||||
if (directory / "skills").is_dir() and (
|
||||
(directory / "catalog.yaml").is_file()
|
||||
or (directory / "registry.yaml").is_file()
|
||||
):
|
||||
return directory
|
||||
return None
|
||||
@@ -0,0 +1,261 @@
|
||||
"""轻量 YAML 读写(覆盖 pouch 使用的子集,无第三方依赖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def safe_load(text: str) -> Any:
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
lines = text.splitlines()
|
||||
# 跳过纯注释/空行,定位首个有效块
|
||||
start = 0
|
||||
while start < len(lines):
|
||||
line = _strip_comment(lines[start])
|
||||
if line.strip():
|
||||
break
|
||||
start += 1
|
||||
if start >= len(lines):
|
||||
return None
|
||||
result, _ = _parse_block(lines, start, _indent_of(_strip_comment(lines[start])))
|
||||
return result
|
||||
|
||||
|
||||
def safe_dump(data: Any, *, allow_unicode: bool = True, sort_keys: bool = False) -> str:
|
||||
del allow_unicode, sort_keys
|
||||
return _dump(data).rstrip() + "\n"
|
||||
|
||||
|
||||
def _indent_of(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
if "#" in line:
|
||||
in_single = False
|
||||
in_double = False
|
||||
for i, ch in enumerate(line):
|
||||
if ch == "'" and not in_double:
|
||||
in_single = not in_single
|
||||
elif ch == '"' and not in_single:
|
||||
in_double = not in_double
|
||||
elif ch == "#" and not in_single and not in_double:
|
||||
return line[:i].rstrip()
|
||||
return line.rstrip()
|
||||
|
||||
|
||||
def _parse_scalar(raw: str) -> Any:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
|
||||
return raw[1:-1]
|
||||
lower = raw.lower()
|
||||
if lower in ("null", "~"):
|
||||
return None
|
||||
if lower == "true":
|
||||
return True
|
||||
if lower == "false":
|
||||
return False
|
||||
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
|
||||
return int(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_block(lines: list[str], start: int, base_indent: int) -> tuple[Any, int]:
|
||||
if start >= len(lines):
|
||||
return None, start
|
||||
|
||||
line = _strip_comment(lines[start])
|
||||
if not line.strip():
|
||||
return _parse_block(lines, start + 1, base_indent)
|
||||
|
||||
stripped = line.lstrip(" ")
|
||||
indent = _indent_of(line)
|
||||
|
||||
if stripped.startswith("- "):
|
||||
return _parse_list(lines, start, indent)
|
||||
|
||||
if ":" in stripped:
|
||||
return _parse_mapping(lines, start, indent)
|
||||
|
||||
raise ValueError(f"无法解析 YAML 行: {line}")
|
||||
|
||||
|
||||
def _parse_list(lines: list[str], start: int, list_indent: int) -> tuple[list[Any], int]:
|
||||
items: list[Any] = []
|
||||
i = start
|
||||
while i < len(lines):
|
||||
line = _strip_comment(lines[i])
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
if _indent_of(line) < list_indent:
|
||||
break
|
||||
if _indent_of(line) > list_indent or not line.lstrip().startswith("- "):
|
||||
break
|
||||
|
||||
content = line.lstrip()[2:].strip()
|
||||
if not content:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ":" in content and not content.startswith(("http://", "https://")):
|
||||
key, rest = content.split(":", 1)
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest:
|
||||
item = {key: _parse_scalar(rest)}
|
||||
i += 1
|
||||
else:
|
||||
nested, i = _parse_mapping(lines, i, list_indent + 2)
|
||||
item = {key: nested}
|
||||
while i < len(lines):
|
||||
nxt = _strip_comment(lines[i])
|
||||
if not nxt.strip():
|
||||
i += 1
|
||||
continue
|
||||
if _indent_of(nxt) <= list_indent:
|
||||
break
|
||||
if not nxt.lstrip().startswith("- "):
|
||||
extra, i = _parse_mapping(lines, i, list_indent + 2)
|
||||
if isinstance(item, dict):
|
||||
item.update(extra)
|
||||
break
|
||||
i += 1
|
||||
items.append(item)
|
||||
continue
|
||||
|
||||
items.append(_parse_scalar(content))
|
||||
i += 1
|
||||
|
||||
return items, i
|
||||
|
||||
|
||||
def _parse_mapping(lines: list[str], start: int, map_indent: int) -> tuple[dict[str, Any], int]:
|
||||
result: dict[str, Any] = {}
|
||||
i = start
|
||||
while i < len(lines):
|
||||
line = _strip_comment(lines[i])
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
indent = _indent_of(line)
|
||||
if indent < map_indent:
|
||||
break
|
||||
if indent > map_indent:
|
||||
raise ValueError(f"缩进不一致: {line}")
|
||||
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- "):
|
||||
break
|
||||
|
||||
key, rest = stripped.split(":", 1)
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
i += 1
|
||||
|
||||
if rest:
|
||||
result[key] = _parse_scalar(rest)
|
||||
continue
|
||||
|
||||
if i >= len(lines):
|
||||
result[key] = None
|
||||
break
|
||||
|
||||
peek = _strip_comment(lines[i])
|
||||
while peek == "" and i < len(lines):
|
||||
i += 1
|
||||
peek = _strip_comment(lines[i]) if i < len(lines) else ""
|
||||
|
||||
if i >= len(lines):
|
||||
result[key] = None
|
||||
break
|
||||
|
||||
child_indent = _indent_of(peek)
|
||||
if child_indent <= map_indent:
|
||||
result[key] = None
|
||||
continue
|
||||
|
||||
if peek.lstrip().startswith("- "):
|
||||
value, i = _parse_list(lines, i, child_indent)
|
||||
result[key] = value
|
||||
else:
|
||||
value, i = _parse_mapping(lines, i, child_indent)
|
||||
result[key] = value
|
||||
|
||||
return result, i
|
||||
|
||||
|
||||
def _dump(data: Any, indent: int = 0) -> str:
|
||||
pad = " " * indent
|
||||
if isinstance(data, dict):
|
||||
if not data:
|
||||
return f"{pad}{{}}\n"
|
||||
lines: list[str] = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
lines.append(f"{pad} - {_scalar(item)}")
|
||||
elif isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
lines.extend(_dump_list_dict_item(item, indent + 2))
|
||||
elif isinstance(value, dict) and value:
|
||||
lines.append(f"{pad}{key}:")
|
||||
lines.append(_dump(value, indent + 2).rstrip())
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
lines.extend(_dump_list_dict_item(item, indent + 2))
|
||||
else:
|
||||
lines.append(f"{pad} - {_scalar(item)}")
|
||||
else:
|
||||
lines.append(f"{pad}{key}: {_scalar(value)}")
|
||||
else:
|
||||
lines.append(f"{pad}{key}: {_scalar(value)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
if isinstance(data, list):
|
||||
lines = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
lines.extend(_dump_list_dict_item(item, indent))
|
||||
else:
|
||||
lines.append(f"{pad}- {_scalar(item)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
return f"{pad}{_scalar(data)}\n"
|
||||
|
||||
|
||||
def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
|
||||
pad = " " * indent
|
||||
lines: list[str] = []
|
||||
first = True
|
||||
for key, value in item.items():
|
||||
is_first = first
|
||||
prefix = f"{pad}- " if is_first else f"{pad} "
|
||||
first = False
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
nested = _dump(value, indent + 2 if is_first else indent + 4)
|
||||
lines.append(nested.rstrip())
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {_scalar(value)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _scalar(value: Any) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
text = str(value)
|
||||
if text == "" or any(ch in text for ch in ":#{}[],&*?|>-%@`") or text.startswith((" ", "-")):
|
||||
return json.dumps(text, ensure_ascii=False)
|
||||
return text
|
||||
Reference in New Issue
Block a user