Files
.pouch/pouch/project.py
T
laily f3cd56b78e 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.
2026-08-25 15:20:02 +08:00

107 lines
3.5 KiB
Python

"""项目级 .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)