diff --git a/AGENTS.md b/AGENTS.md index 643d22f..9819a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,24 +43,24 @@ skills/ │ └── reference.md skiff/ # CLI 源码(Python 3) bin/skiff # CLI 入口 -registry.yaml # 外部 Git skill 来源目录 +catalog.yaml # skiff 预置 Skill 来源目录 AGENTS.md # 本文档 ``` -**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md` +**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`catalog.yaml`、`AGENTS.md` **本仓库不包含**:各项目的 skill 启用清单 --- ## Skill 目录 -### 自研(Owned) +### 内置(Builtin) | Skill | 说明 | | ---------------------------------------------------------------------- | ------------------------------------------------- | | [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 | -| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill | +| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md | @@ -75,19 +75,19 @@ Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目 skiff init ack ``` -### 外部(External Git) +### 预置目录(Catalog) -在 `registry.yaml` 中注册,通过 skiff 拉取安装: +在 `catalog.yaml` 中预置,通过 skiff 拉取安装: -| Skill | 来源 | -| ----------- | -------------------------------------------------------------------------- | -| superpowers | [https://github.com/obra/superpowers](https://github.com/obra/superpowers) | +| Source | 来源 | +| --- | --- | +| waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) | ```bash -skiff fetch superpowers -skiff install-external superpowers +skiff fetch waza +skiff add waza/think -g ``` ### 自定义仓库(Custom Sources) @@ -139,7 +139,7 @@ npx skills find typescript skills 仓库(本仓库) skiff CLI skills// ←── skiff install / enable skiff/ ←── python3 -m skiff -registry.yaml ←── skiff add / fetch +catalog.yaml ←── skiff add / fetch ↑ ~/.skills(symlink) │ @@ -155,8 +155,8 @@ registry.yaml ←── skiff add / fetch | 层级 | 位置 | 维护方式 | | ---------------- | ---------------------------------- | ------------------------------- | -| **Owned** | `skills//` | 本仓库 commit | -| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` | +| **Builtin** | `skills//` | 本仓库 commit | +| **Catalog** | `catalog.yaml` + checkout 缓存 | `skiff catalog add / fetch` | | **Custom Source** | `~/.local/share/skiff/sources/` 或本地路径 | `skiff source add/fetch` | | **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` | @@ -186,10 +186,10 @@ registry.yaml ←── skiff add / fetch ```yaml # .skills.yaml(在项目根目录) skills: - - declarative-openspec-loop - - name: superpowers - source: registry - ref: main + - name: declarative-openspec-loop + source: builtin + - name: think + source: catalog:waza - name: internal-review source: company @@ -231,9 +231,9 @@ skiff sync | `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent | | `skiff list` | 列出所有 skill | | `skiff status` | 安装状态总览 | -| `skiff install ` | 全局安装(symlink) | -| `skiff uninstall ` | 移除 symlink | -| `skiff add / fetch / install-external` | 外部 Git skill | +| `skiff add [-g]` | 项目或全局安装(symlink) | +| `skiff remove [-g]` | 移除 symlink | +| `skiff catalog add` / `skiff fetch` | 管理和拉取 catalog source | ### 草稿与健康检查 diff --git a/README.md b/README.md index 550cc3d..26e6222 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ git clone https://git.yumee.top/laily/skills.git ~/.skills cd ~/.skills ./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent -skiff install declarative-openspec-loop +skiff add declarative-openspec-loop -g skiff select # 交互式选择并批量安装 skiff list skiff status @@ -21,7 +21,7 @@ skiff status skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md skiff/ # CLI 源码(Python 3) bin/skiff # CLI 入口 -registry.yaml # 外部 Git skill 目录 +catalog.yaml # skiff 预置 Skill 来源目录 AGENTS.md # 详细规范与架构说明 ``` @@ -29,7 +29,7 @@ AGENTS.md # 详细规范与架构说明 |------|------| | [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts | | [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI | -| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 | +| [catalog.yaml](catalog.yaml) | skiff 预置 Skill 来源目录 | | [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 | ## 自研 Skill @@ -37,7 +37,7 @@ AGENTS.md # 详细规范与架构说明 | Skill | 说明 | |-------|------| | [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 | -| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill | +| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 | @@ -65,8 +65,8 @@ skiff add my-skill -g # 全局安装验证 ### 全局(用户级) ```bash -skiff install # 安装到 ~/.cursor/skills/ 等 -skiff install --target cursor +skiff add -g # 安装到 ~/.cursor/skills/ 等 +skiff add -g -a cursor ``` ### 项目级 @@ -75,10 +75,10 @@ skiff install --target cursor ```yaml skills: - - declarative-openspec-loop - - name: superpowers - source: registry - ref: main + - name: declarative-openspec-loop + source: builtin + - name: think + source: catalog:waza targets: # 可选,默认 all - cursor @@ -87,18 +87,25 @@ targets: # 可选,默认 all ``` ```bash -skiff enable declarative-openspec-loop +skiff add declarative-openspec-loop skiff sync -skiff disable declarative-openspec-loop +skiff remove declarative-openspec-loop ``` -## 外部 Skill +## Catalog 与 Custom Source -**Git 来源**(经 skiff 管理): +安装 catalog 中预置的来源: ```bash -skiff fetch superpowers -skiff install-external superpowers +skiff fetch waza +skiff add waza/think -g +``` + +接入团队自己的本地目录或 Git 仓库: + +```bash +skiff source add company --local ~/code/company-skills --skills-path skills +skiff add company/internal-review -g ``` **社区来源**(Vercel CLI): @@ -113,7 +120,7 @@ npx skills find typescript ``` 本仓库 ├── skills// ←── skiff install / enable -├── registry.yaml ←── skiff add / fetch +├── catalog.yaml ←── 预置来源发现与 fetch └── skiff/ ←── python3 -m skiff ↑ ~/.skills(symlink) diff --git a/registry.yaml b/catalog.yaml similarity index 79% rename from registry.yaml rename to catalog.yaml index d579163..d7c7eaa 100644 --- a/registry.yaml +++ b/catalog.yaml @@ -1,4 +1,4 @@ -# External skills registry +# Preconfigured Skill source catalog # Format: # : # repo: @@ -20,15 +20,6 @@ # ref: main # path: skills -skills: - repo: ~/.skills - ref: main - path: skills - description: 本地自研 Agent Skills 集合,由 skiff 统一创建、维护和安装。 - tags: - - skill-management - - owned - waza: repo: https://github.com/tw93/Waza.git ref: main diff --git a/skiff/README.md b/skiff/README.md index 6ccb6cc..33785f1 100644 --- a/skiff/README.md +++ b/skiff/README.md @@ -14,7 +14,7 @@ cd /path/to/skills # 本仓库根目录 ## 命令风格 接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`, -统一管理 **~/.skills 自研 skill**、命名 custom source 和 registry 外部 skill。 +统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。 ```bash # 浏览可用自研 skill @@ -68,14 +68,14 @@ skiff bootstrap | `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 | | `skiff init [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 | -### 全局安装(自研 skill) +### Skill 安装 | 命令 | 说明 | |------|------| | `skiff add [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) | | `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill | | `skiff remove [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) | -| `skiff add --list` | 列出可用自研 skill | +| `skiff add --list` | 列出可用 builtin skill | | `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push | 旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。 @@ -88,16 +88,16 @@ skiff bootstrap | claude | `~/.claude/skills/` | | codex | `~/.codex/skills/` | -### 外部 Git skill +### 预置 Catalog Source | 命令 | 说明 | |------|------| -| `skiff registry add [--ref main] [--path .]` | 写入 `registry.yaml` | -| `skiff fetch ` | 克隆或更新外部仓库缓存 | -| `skiff add [-g] [-a AGENT...]` | 安装 registry 中的单个 skill 或完整 collection(缺失时自动 fetch) | +| `skiff catalog add [--ref main] [--path .]` | 写入 `catalog.yaml` | +| `skiff fetch ` | 克隆或更新 catalog source checkout | +| `skiff add [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) | | `skiff add / [...]` | 只安装 collection 中指定的 skill | -`registry.yaml` 条目可额外提供 `description` 和 `tags`。`description` +`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description` 会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含 `SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。 collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add ` @@ -202,12 +202,12 @@ cd ~/code/my-app skiff add declarative-openspec-loop -a cursor -y ``` -### 安装外部 Git skill +### 添加 Catalog Source ```bash -skiff add my-ext https://github.com/org/repo --ref main +skiff catalog add my-ext https://github.com/org/repo --ref main skiff fetch my-ext -skiff install-external my-ext +skiff add my-ext -g ``` ## 源码结构 @@ -218,8 +218,8 @@ skiff/ ├── __main__.py # python3 -m skiff 入口 ├── cli.py # 命令定义与调度 ├── paths.py # 路径常量与 Agent 目标 -├── skills.py # 自研 skill 发现 -├── registry.py # registry.yaml 读写 +├── skills.py # builtin/catalog/custom 统一解析 +├── catalog.py # catalog.yaml 读写与 Skill 发现 ├── sources.py # custom source 配置、发现与 Git 管理 ├── project.py # .skills.yaml 管理 ├── symlinks.py # 软链创建/检查/修复 @@ -233,9 +233,9 @@ skiff/ | 变量 | 路径 | 说明 | |------|------|------| | `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) | -| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 | -| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 | -| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库;新条目按 repo/ref 共享缓存 | +| `SKILLS_DIR` | `~/.skills/skills/` | builtin skill 目录 | +| `CATALOG_FILE` | `~/.skills/catalog.yaml` | 预置 Skill 来源目录 | +| `CATALOG_CACHE_DIR` | `~/.local/share/skills/externals/` | catalog checkout 兼容缓存;按 repo/ref 共享 | | `CONFIG_FILE` | `~/.config/skiff/config.yaml` | custom source 配置 | | `SOURCES_DIR` | `~/.local/share/skiff/sources/` | custom Git source 默认 checkout | diff --git a/skiff/__init__.py b/skiff/__init__.py index 8a71d91..70c20e3 100644 --- a/skiff/__init__.py +++ b/skiff/__init__.py @@ -1,3 +1,3 @@ """skiff — Agent Skills 安装与管理 CLI。""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/skiff/registry.py b/skiff/catalog.py similarity index 56% rename from skiff/registry.py rename to skiff/catalog.py index 9fa69a7..e4fc2ca 100644 --- a/skiff/registry.py +++ b/skiff/catalog.py @@ -1,4 +1,4 @@ -"""registry.yaml 读写。""" +"""预置 Skill catalog 的配置、发现与本地 checkout 路径。""" from __future__ import annotations @@ -7,75 +7,77 @@ from pathlib import Path from typing import Any from skiff import yaml_io -from skiff.paths import REGISTRY_FILE +from skiff.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE -def load_registry(path: Path | None = None) -> dict[str, dict[str, Any]]: - path = path or 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"registry 格式错误: {path}") + raise SystemExit(f"catalog 格式错误: {path}") return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")} -def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) -> None: - path = path or REGISTRY_FILE +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 registry_repo(entry: dict[str, Any]) -> str: +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 external_repo_path(entry: dict[str, Any]) -> Path: +def catalog_repo_path(entry: dict[str, Any]) -> Path: """Return the shared checkout path for a repo/ref pair.""" - from skiff.paths import EXTERNALS_DIR + from skiff.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 EXTERNALS_DIR / "_repos" / digest + return CATALOG_CACHE_DIR / "_repos" / digest -def external_checkout_path(name: str, entry: dict[str, Any]) -> Path: +def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path: """Use a local repo directly, otherwise return its external checkout.""" - from skiff.paths import EXTERNALS_DIR + from skiff.paths import CATALOG_CACHE_DIR configured_repo = str(entry.get("repo", "")) - local_repo = Path(registry_repo(entry)) + local_repo = Path(catalog_repo(entry)) if configured_repo.startswith("~") and local_repo.is_dir(): return local_repo.resolve() - legacy = EXTERNALS_DIR / name - return legacy if legacy.is_dir() else external_repo_path(entry) + legacy = CATALOG_CACHE_DIR / name + return legacy if legacy.is_dir() else catalog_repo_path(entry) -def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path: - entry = entry or load_registry().get(name, {}) +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 = external_checkout_path(name, entry).resolve() + 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"registry 条目 {name!r} 的 path 超出外部仓库: {subpath!r}" + f"catalog 条目 {name!r} 的 path 超出来源仓库: {subpath!r}" ) from exc return skill_path -def discover_external_skills( +def discover_catalog_skills( name: str, entry: dict[str, Any] | None = None, ) -> dict[str, Path]: - """Discover a single registry skill or a collection of sibling skills.""" - entry = entry or load_registry().get(name, {}) - root = external_skill_path(name, entry) + """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(): @@ -101,18 +103,3 @@ def discover_external_skills( continue skills[item.name] = item return skills - - -def external_collection_skill_path( - collection: str, - skill_name: str, - entry: dict[str, Any] | None = None, -) -> Path: - skills = discover_external_skills(collection, entry) - if skill_name not in skills: - available = ", ".join(skills) or "(无)" - raise SystemExit( - f"registry collection {collection!r} 中找不到 skill {skill_name!r}。" - f"可用: {available}" - ) - return skills[skill_name] diff --git a/skiff/cli.py b/skiff/cli.py index 441f9ed..a8b7397 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -15,7 +15,7 @@ from skiff.gitops import publish as git_publish from skiff.paths import ( ALL_TARGETS, DRAFTS_DIR, - EXTERNALS_DIR, + CATALOG_CACHE_DIR, CONFIG_FILE, SKILLS_DIR, SKILLS_HOME, @@ -29,22 +29,22 @@ from skiff.project import ( load_manifest, remove_skill_from_manifest, resolve_manifest_skill, - save_manifest, ) -from skiff.registry import ( - discover_external_skills, - external_checkout_path, - external_skill_path, - load_registry, - registry_repo, - save_registry, +from skiff.catalog import ( + discover_catalog_skills, + catalog_checkout_path, + catalog_skill_path, + load_catalog, + catalog_repo, + save_catalog, ) from skiff.selector import SkillChoice, select_skills from skiff.skills import ( list_custom_skills, - list_owned_skills, - owned_skill_path, + list_builtin_skills, + builtin_skill_path, read_skill_meta, + normalize_source, resolve_skill_source, split_skill_spec, skill_description, @@ -52,6 +52,7 @@ from skiff.skills import ( validate_skill_name, ) from skiff.sources import ( + discover_source_skills, fetch_source, load_sources, save_sources, @@ -71,10 +72,6 @@ def _err(msg: str) -> None: print(msg, file=sys.stderr) -def _warn_deprecated(old: str, new: str) -> None: - _err(f"警告: `{old}` 已弃用,请改用 `{new}`") - - def _project_root(explicit: str | None = None) -> Path: if explicit: return Path(explicit).resolve() @@ -93,13 +90,13 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None if flagged: names.extend(flagged) if "*" in names: - return list_owned_skills() + return list_builtin_skills() return names def _ensure_source_fetched(name: str, source: str | None = None) -> None: name, source = split_skill_spec(name, source) - registry = load_registry() + catalog = load_catalog() sources = load_sources() if source in sources: root = source_skills_root(source, sources[source]) @@ -108,68 +105,68 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None: fetch_source(source, sources[source]) return - registry_name = ( + catalog_name = ( source.split(":", 1)[1] - if source and source.startswith("registry:") + if source and source.startswith("catalog:") else source - if source in registry + if source in catalog else name - if name in registry + if name in catalog else None ) - if registry_name and ( - source in (None, "registry", registry_name) - or source == f"registry:{registry_name}" + if catalog_name and ( + source in (None, "catalog", catalog_name) + or source == f"catalog:{catalog_name}" ): - _ensure_registry_fetched(registry_name, registry[registry_name]) + _ensure_catalog_fetched(catalog_name, catalog[catalog_name]) return - if source not in (None, "registry"): + if source not in (None, "catalog"): return - if name not in registry: + if name not in catalog: return - _ensure_registry_fetched(name, registry[name]) + _ensure_catalog_fetched(name, catalog[name]) -def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None: - path = external_skill_path(name, entry) - if (path / "SKILL.md").is_file() or discover_external_skills(name, entry): +def _ensure_catalog_fetched(name: str, entry: dict[str, object]) -> None: + path = catalog_skill_path(name, entry) + if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry): return - repo = registry_repo(entry) + repo = catalog_repo(entry) ref = entry.get("ref", "main") - dest = external_checkout_path(name, entry) + dest = catalog_checkout_path(name, entry) dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): raise SystemExit( - f"外部仓库已存在但 skill 路径无效: {external_skill_path(name, entry)}" + f"外部仓库已存在但 skill 路径无效: {catalog_skill_path(name, entry)}" ) _print(f"拉取外部 skill: {name}") subprocess.run( ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)], check=True, ) - if not discover_external_skills(name, entry): + if not discover_catalog_skills(name, entry): raise SystemExit( - f"registry 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}" + f"catalog 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}" ) -def _registry_skill_names( +def _catalog_skill_names( name: str, entry: dict[str, object] | None = None, ) -> list[str]: - entry = entry or load_registry().get(name) + entry = entry or load_catalog().get(name) if not entry: - raise SystemExit(f"registry 中不存在: {name}") - _ensure_registry_fetched(name, entry) - names = list(discover_external_skills(name, entry)) + raise SystemExit(f"catalog 中不存在: {name}") + _ensure_catalog_fetched(name, entry) + names = list(discover_catalog_skills(name, entry)) for skill_name in names: validate_skill_name(skill_name) if not names: - raise SystemExit(f"registry 条目 {name!r} 中没有可安装的 skill") + raise SystemExit(f"catalog 条目 {name!r} 中没有可安装的 skill") return names @@ -178,34 +175,39 @@ def _expand_install_request( explicit_source: str | None = None, ) -> list[tuple[str, str | None]]: name, source = split_skill_spec(spec, explicit_source) - registry = load_registry() - if source in load_sources(): + catalog = load_catalog() + sources = load_sources() + if source in sources: return [(name, source)] - if source == "registry" and name in registry: - available = _registry_skill_names(name, registry[name]) - root = external_skill_path(name, registry[name]) + if source == "catalog" and name in catalog: + available = _catalog_skill_names(name, catalog[name]) + root = catalog_skill_path(name, catalog[name]) if (root / "SKILL.md").is_file(): - return [(name, "registry")] - return [(skill_name, f"registry:{name}") for skill_name in available] - if source in registry: - available = _registry_skill_names(source, registry[source]) + return [(name, "catalog")] + return [(skill_name, f"catalog:{name}") for skill_name in available] + if source in catalog: + available = _catalog_skill_names(source, catalog[source]) if name not in available: raise SystemExit( - f"registry collection {source!r} 中找不到 skill {name!r}" + f"catalog collection {source!r} 中找不到 skill {name!r}" ) - return [(name, f"registry:{source}")] - if source is None and name in registry: - available = _registry_skill_names(name, registry[name]) - root = external_skill_path(name, registry[name]) + return [(name, f"catalog:{source}")] + if source is None and name in catalog: + available = _catalog_skill_names(name, catalog[name]) + root = catalog_skill_path(name, catalog[name]) if (root / "SKILL.md").is_file(): - return [(name, "registry")] - return [(skill_name, f"registry:{name}") for skill_name in available] + return [(name, f"catalog:{name}")] + return [(skill_name, f"catalog:{name}") for skill_name in available] + if source is None and name in sources: + _ensure_source_fetched(name, name) + available = discover_source_skills(name, sources[name]) + if not available: + raise SystemExit(f"custom source {name!r} 中没有可安装的 skill") + return [(skill_name, name) for skill_name in available] return [(name, source)] def _manifest_source_details(resolved_source: str) -> tuple[str, dict[str, object]]: - if resolved_source.startswith("registry:"): - return "registry", {"registry": resolved_source.split(":", 1)[1]} return resolved_source, {} @@ -329,27 +331,37 @@ def _remove_skill( def cmd_list(args: argparse.Namespace) -> None: ensure_skills_home() - owned = list_owned_skills() - registry = load_registry() + source_filter = normalize_source(args.source) + builtin = list_builtin_skills() + catalog = load_catalog() custom = ( {} - if args.source in ("owned", "registry") - else list_custom_skills(args.source) + if source_filter in ("builtin", "catalog") + or (source_filter and source_filter.startswith("catalog:")) + else list_custom_skills(source_filter) ) - if args.source in (None, "owned"): - _print("自研 (owned):") - for name in owned: + if source_filter in (None, "builtin"): + _print("内置 (builtin):") + for name in builtin: _print(f" {name}") - if args.source in (None, "registry"): - _print("\n外部 (registry):") - if not registry: + if source_filter in (None, "catalog"): + _print("\n目录 (catalog):") + if not catalog: _print(" (无)") else: - for name, entry in registry.items(): + for name, entry in catalog.items(): repo = entry.get("repo", "?") _print(f" {name} ({repo})") + elif source_filter and source_filter.startswith("catalog:"): + provider = source_filter.split(":", 1)[1] + if provider not in catalog: + raise SystemExit(f"catalog 中不存在: {provider}") + _ensure_catalog_fetched(provider, catalog[provider]) + _print(f"目录 (catalog:{provider}):") + for name in discover_catalog_skills(provider, catalog[provider]): + _print(f" {name}") for source, names in custom.items(): _print(f"\n自定义 ({source}):") @@ -363,9 +375,9 @@ def cmd_bootstrap(args: argparse.Namespace) -> None: del args ensure_skills_home() project_skill = "skiff" - owned_skill_path(project_skill) + builtin_skill_path(project_skill) _install_skill(project_skill, list(ALL_TARGETS), project_root=None) - _print("已安装项目 skill 到所有 agent") + _print("已全局安装 builtin skiff skill 到所有 agent") def cmd_update(args: argparse.Namespace) -> None: @@ -393,20 +405,20 @@ def _installed_links( def cmd_status(args: argparse.Namespace) -> None: ensure_skills_home() targets = resolve_agent_args(flatten_agent_args(args.agents)) - owned = list_owned_skills() - registry = load_registry() + builtin = list_builtin_skills() + catalog = load_catalog() custom = list_custom_skills() - entries = [("owned", name) for name in owned] - unfetched_registry: list[str] = [] - for package, entry in registry.items(): - discovered = discover_external_skills(package, entry) + entries = [("builtin", name) for name in builtin] + unfetched_catalog: list[str] = [] + for package, entry in catalog.items(): + discovered = discover_catalog_skills(package, entry) if not discovered: - unfetched_registry.append(package) - elif (external_skill_path(package, entry) / "SKILL.md").is_file(): - entries.append(("registry", package)) + unfetched_catalog.append(package) + elif (catalog_skill_path(package, entry) / "SKILL.md").is_file(): + entries.append((f"catalog:{package}", package)) else: entries.extend( - (f"registry:{package}", skill_name) + (f"catalog:{package}", skill_name) for skill_name in discovered ) entries.extend((source, name) for source, names in custom.items() for name in names) @@ -414,8 +426,8 @@ def cmd_status(args: argparse.Namespace) -> None: _print(f"skills 仓库: {SKILLS_HOME.resolve()}") _print(f"agents: {', '.join(targets)}\n") - for package in unfetched_registry: - _print(f"[registry] {package}\n (未 fetch)\n") + for package in unfetched_catalog: + _print(f"[catalog] {package}\n (未 fetch)\n") for source, name in entries: _print(f"[{source}] {name}") @@ -438,13 +450,13 @@ def cmd_status(args: argparse.Namespace) -> None: def _print_available_skills() -> None: ensure_skills_home() - owned = list_owned_skills() - if not owned: + builtin = list_builtin_skills() + if not builtin: _print("~/.skills/skills/ 中没有自研 skill") return _print(f"来源: {SKILLS_DIR}\n") - for name in owned: + for name in builtin: desc = skill_description(name) _print(f" {name}") if desc: @@ -466,15 +478,16 @@ def cmd_add(args: argparse.Namespace) -> None: if args.all: if args.source: - if args.source == "owned": - names = list_owned_skills() - elif args.source == "registry": - names = list(load_registry()) + source = normalize_source(args.source) + if source == "builtin": + names = list_builtin_skills() + elif source == "catalog": + names = list(load_catalog()) else: - _ensure_source_fetched("", args.source) - names = list_custom_skills(args.source)[args.source] + _ensure_source_fetched("", source) + names = list_custom_skills(source)[source] else: - names = list_owned_skills() + names = list_builtin_skills() targets = resolve_agent_args(["*"]) else: names = _collect_skill_names(args.skills, args.skills_flag) @@ -549,9 +562,9 @@ def cmd_select(args: argparse.Namespace) -> None: targets = resolve_agent_args(flatten_agent_args(args.agents)) project_root = None if args.global_scope else _project_root(args.project) - registry = load_registry() - owned_names = list_owned_skills() - for name in [*owned_names, *registry]: + catalog = load_catalog() + builtin_names = list_builtin_skills() + for name in [*builtin_names, *catalog]: validate_skill_name(name) def make_choice( @@ -586,69 +599,101 @@ def cmd_select(args: argparse.Namespace) -> None: name=name, installed_name=name, expected=SKILLS_DIR / name, - kind="owned", + kind="builtin", description=skill_description(name) or "", ) - for name in owned_names + for name in builtin_names ] choice_requests: dict[str, tuple[str, str]] = { - name: (name, "owned") for name in owned_names + name: (name, "builtin") for name in builtin_names } - for package, entry in registry.items(): - skill_names = _registry_skill_names(package, entry) - root = external_skill_path(package, entry) - if (root / "SKILL.md").is_file(): - if package in choice_requests: - _err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {package}") - continue + + def add_provider_choices( + provider: str, + *, + registration: str, + discovered: dict[str, Path], + description: str, + ) -> None: + source = provider if registration == "custom" else f"catalog:{provider}" + if len(discovered) == 1 and provider in discovered: + expected = discovered[provider] + if provider in choice_requests: + _err( + f"警告: {registration} source 与已有 skill 同名," + f"已忽略: {provider}" + ) + return choices.append( make_choice( - name=package, - installed_name=package, - expected=root, - kind="external", - description=str(entry.get("description", "")), + name=provider, + installed_name=provider, + expected=expected, + kind=f"{registration}:{provider}", + description=description, ) ) - choice_requests[package] = (package, "registry") - continue - for skill_name in skill_names: - expected = root / skill_name + choice_requests[provider] = (provider, source) + return + + child_names: list[str] = [] + first_child = len(choices) + for skill_name, expected in discovered.items(): if ( - skill_name in owned_names + skill_name in builtin_names and expected.resolve() == (SKILLS_DIR / skill_name).resolve() ): continue - choice_name = f"{package}/{skill_name}" - description = read_skill_meta(expected).get("description", "") + choice_name = f"{provider}/{skill_name}" choices.append( make_choice( name=choice_name, installed_name=skill_name, expected=expected, - kind=f"external:{package}", - description=description, + kind=f"{registration}:{provider}", + description=read_skill_meta(expected).get("description", ""), indent=1, ) ) - choice_requests[choice_name] = (skill_name, f"registry:{package}") - child_names = tuple( - f"{package}/{skill_name}" - for skill_name in skill_names - if f"{package}/{skill_name}" in choice_requests - ) + choice_requests[choice_name] = (skill_name, source) + child_names.append(choice_name) if child_names: - first_child = len(choices) - len(child_names) choices.insert( first_child, SkillChoice( - name=package, - kind="repository", - description=str(entry.get("description", "")), - children=child_names, + name=provider, + kind=f"{registration} source", + description=description, + children=tuple(child_names), ), ) + for package, entry in catalog.items(): + skill_names = _catalog_skill_names(package, entry) + root = catalog_skill_path(package, entry) + discovered = ( + {package: root} + if (root / "SKILL.md").is_file() + else {skill_name: root / skill_name for skill_name in skill_names} + ) + add_provider_choices( + package, + registration="catalog", + discovered=discovered, + description=str(entry.get("description", "")), + ) + + custom_sources = load_sources() + for provider, entry in custom_sources.items(): + validate_skill_name(provider) + _ensure_source_fetched("", provider) + add_provider_choices( + provider, + registration="custom", + discovered=discover_source_skills(provider, entry), + description=str(entry.get("description", "")), + ) + try: scope_label = ( "全局" @@ -697,31 +742,13 @@ def cmd_select(args: argparse.Namespace) -> None: entry_targets = targets if args.agents else None for name in sorted(successful): skill_name, source = choice_requests[name] - if source == "owned": - add_skill_to_manifest( - manifest_path, - skill_name, - source="owned", - extra={"targets": entry_targets} if entry_targets else None, - ) - else: - package = ( - skill_name - if source == "registry" - else source.split(":", 1)[1] - ) - entry = registry[package] - extra: dict[str, object] = {"ref": entry.get("ref", "main")} - if source != "registry": - extra["registry"] = package - if entry_targets: - extra["targets"] = entry_targets - add_skill_to_manifest( - manifest_path, - skill_name, - source="registry", - extra=extra, - ) + extra = {"targets": entry_targets} if entry_targets else None + add_skill_to_manifest( + manifest_path, + skill_name, + source=source, + extra=extra, + ) installed_count = len(names) - len(failures) if not names: @@ -745,24 +772,30 @@ def cmd_remove(args: argparse.Namespace) -> None: if not names: raise SystemExit("请指定 skill 名称,或使用 skiff remove --all") - registry = load_registry() + catalog = load_catalog() + sources = load_sources() expanded: list[str] = [] for spec in names: name, source = split_skill_spec(spec) - if source in registry and source not in load_sources(): - if name not in discover_external_skills(source, registry[source]): + if source and source.startswith("catalog:"): + provider = source.split(":", 1)[1] + if provider not in catalog: + raise SystemExit(f"catalog 中不存在: {provider}") + if name not in discover_catalog_skills(provider, catalog[provider]): raise SystemExit( - f"registry collection {source!r} 中找不到 skill {name!r}" + f"catalog source {provider!r} 中找不到 skill {name!r}" ) expanded.append(name) - elif source is None and name in registry: - discovered = discover_external_skills(name, registry[name]) - root = external_skill_path(name, registry[name]) + elif source is None and name in catalog: + discovered = discover_catalog_skills(name, catalog[name]) + root = catalog_skill_path(name, catalog[name]) expanded.extend( [name] if (root / "SKILL.md").is_file() else list(discovered) ) + elif source is None and name in sources: + expanded.extend(discover_source_skills(name, sources[name])) else: expanded.append(name) @@ -787,34 +820,34 @@ def cmd_publish(args: argparse.Namespace) -> None: ) -def cmd_registry_add(args: argparse.Namespace) -> None: +def cmd_catalog_add(args: argparse.Namespace) -> None: ensure_skills_home() validate_skill_name(args.name) - registry = load_registry() - if args.name in registry: - raise SystemExit(f"registry 中已存在: {args.name}") + catalog = load_catalog() + if args.name in catalog: + raise SystemExit(f"catalog 中已存在: {args.name}") - registry[args.name] = { + catalog[args.name] = { "repo": args.repo, "ref": args.ref, "path": args.path, } - save_registry(registry) - _print(f"已添加 registry 条目: {args.name}") + save_catalog(catalog) + _print(f"已添加 catalog 条目: {args.name}") def cmd_fetch(args: argparse.Namespace) -> None: ensure_skills_home() - registry = load_registry() - if args.name not in registry: - raise SystemExit(f"registry 中不存在: {args.name}") + catalog = load_catalog() + if args.name not in catalog: + raise SystemExit(f"catalog 中不存在: {args.name}") - entry = registry[args.name] - repo = registry_repo(entry) + entry = catalog[args.name] + repo = catalog_repo(entry) ref = entry.get("ref", "main") - dest = external_checkout_path(args.name, entry) + dest = catalog_checkout_path(args.name, entry) - EXTERNALS_DIR.mkdir(parents=True, exist_ok=True) + CATALOG_CACHE_DIR.mkdir(parents=True, exist_ok=True) if dest.exists(): _print(f"更新: {dest}") @@ -917,40 +950,6 @@ def cmd_source_remove(args: argparse.Namespace) -> None: _print(f"已删除 checkout(不可恢复): {checkout}") -def cmd_enable(args: argparse.Namespace) -> None: - _warn_deprecated("skiff enable", "skiff add ") - ensure_skills_home() - validate_skill_name(args.name) - root = _project_root(args.project) - manifest_path = root / ".skills.yaml" - - name, source = split_skill_spec(args.name) - _ensure_source_fetched(name, source) - _, resolved_source = resolve_skill_source(name, source=source) - add_skill_to_manifest(manifest_path, name, source=resolved_source) - - targets = resolve_agent_args(flatten_agent_args(args.agents)) - _, data = load_manifest(manifest_path) - manifest_targets = data.get("targets") - if manifest_targets: - targets = [t for t in targets if t in manifest_targets] - - _install_skill(name, targets, project_root=root, source=resolved_source) - _print(f"已启用项目 skill: {resolved_source}/{name} @ {root}") - - -def cmd_disable(args: argparse.Namespace) -> None: - _warn_deprecated("skiff disable", "skiff remove ") - root = _project_root(args.project) - manifest_path = root / ".skills.yaml" - if not remove_skill_from_manifest(manifest_path, args.name): - _print(f"manifest 中不存在: {args.name}") - return - - targets = resolve_agent_args(flatten_agent_args(args.agents)) - _remove_skill(args.name, targets, project_root=root) - - def cmd_sync(args: argparse.Namespace) -> None: ensure_skills_home() root = _project_root(args.project) @@ -967,8 +966,6 @@ def cmd_sync(args: argparse.Namespace) -> None: for entry in iter_manifest_skills(data): name = entry["name"] source = entry.get("source") - if source == "registry" and entry.get("registry"): - source = f"registry:{entry['registry']}" _ensure_source_fetched(name, source) skill_path, _ = resolve_manifest_skill(entry) skill_targets = targets @@ -1022,20 +1019,20 @@ def cmd_create(args: argparse.Namespace) -> None: _print(f"完成后运行: skiff check {args.name} && skiff finalize {args.name}") -def _draft_or_owned_path(name: str) -> tuple[Path, str]: +def _draft_or_builtin_path(name: str) -> tuple[Path, str]: draft = DRAFTS_DIR / name if draft.is_dir(): return draft, "草稿" - owned = SKILLS_DIR / name - if owned.is_dir(): - return owned, "正式 skill" + builtin = SKILLS_DIR / name + if builtin.is_dir(): + return builtin, "正式 skill" raise SystemExit(f"找不到草稿或正式 skill: {name}") def cmd_check(args: argparse.Namespace) -> None: ensure_skills_home() validate_skill_name(args.name) - path, kind = _draft_or_owned_path(args.name) + path, kind = _draft_or_builtin_path(args.name) issues = validate_skill_dir(path, args.name) if kind == "草稿": issues = [issue for issue in issues if "草稿文件: brief.yaml" not in issue] @@ -1090,8 +1087,8 @@ def cmd_doctor(args: argparse.Namespace) -> None: _err("✗ ~/.skills 未正确配置") issues += 1 - for name in list_owned_skills(): - for target, link, expected in _installed_links(name, targets, source="owned"): + for name in list_builtin_skills(): + for target, link, expected in _installed_links(name, targets, source="builtin"): status = check_link(link, expected) if status.ok: continue @@ -1104,9 +1101,9 @@ def cmd_doctor(args: argparse.Namespace) -> None: except Exception as exc: # noqa: BLE001 _err(f" 修复失败: {exc}") - registry = load_registry() - for name in registry: - ext = external_skill_path(name, registry[name]) + catalog = load_catalog() + for name in catalog: + ext = catalog_skill_path(name, catalog[name]) if not ext.exists(): _err(f"✗ 外部 skill 未 fetch: {name}") issues += 1 @@ -1224,7 +1221,7 @@ def build_parser() -> argparse.ArgumentParser: p_update.set_defaults(func=cmd_update) p_list = sub.add_parser("list", help="列出所有 source 中的 skill") - p_list.add_argument("--source", help="只列出指定来源(owned、registry 或 custom source)") + p_list.add_argument("--source", help="只列出指定来源(builtin、catalog 或 custom source)") p_list.set_defaults(func=cmd_list) p_status = sub.add_parser("status", help="安装状态总览") @@ -1233,13 +1230,13 @@ def build_parser() -> argparse.ArgumentParser: p_add = sub.add_parser( "add", - help="安装 skill 到 agent(自研或 registry)", - description="安装 ~/.skills 中的自研 skill,或 registry 中的外部 skill", + help="安装 builtin、catalog 或 custom source 中的 skill", + description="安装 builtin、catalog 或 custom source 中的 skill", ) p_add.add_argument("skills", nargs="*", metavar="skill", help="skill 名称(可多个)") p_add.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="SKILL") - p_add.add_argument("--list", dest="list_available", action="store_true", help="列出可用自研 skill,不安装") - p_add.add_argument("--all", action="store_true", help="安装全部自研 skill 到全部 agent") + p_add.add_argument("--list", dest="list_available", action="store_true", help="列出可用 builtin skill,不安装") + p_add.add_argument("--all", action="store_true", help="安装指定来源的全部 skill 到全部 agent") p_add.add_argument("--source", help="指定 skill 来源(也可使用 source/name)") _add_common_flags(p_add) p_add.set_defaults(func=cmd_add) @@ -1272,20 +1269,20 @@ def build_parser() -> argparse.ArgumentParser: p_publish.add_argument("--no-commit", action="store_true", help="只 git add,不 commit") p_publish.set_defaults(func=cmd_publish) - p_registry = sub.add_parser("registry", help="管理 registry.yaml 中的外部 skill") - registry_sub = p_registry.add_subparsers(dest="registry_command", required=True) - p_reg_add = registry_sub.add_parser("add", help="注册外部 Git skill") - p_reg_add.add_argument("name", help="registry 名称") - p_reg_add.add_argument("repo", help="Git 仓库 URL") - p_reg_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)") - p_reg_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)") - p_reg_add.set_defaults(func=cmd_registry_add) + p_catalog = sub.add_parser("catalog", help="管理 catalog.yaml 中的预置来源") + catalog_sub = p_catalog.add_subparsers(dest="catalog_command", required=True) + p_catalog_add = catalog_sub.add_parser("add", help="添加预置 Git source") + p_catalog_add.add_argument("name", help="catalog source 名称") + p_catalog_add.add_argument("repo", help="Git 仓库 URL") + p_catalog_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)") + p_catalog_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)") + p_catalog_add.set_defaults(func=cmd_catalog_add) - p_fetch = sub.add_parser("fetch", help="拉取/更新 registry 中的外部 skill") - p_fetch.add_argument("name", help="registry 名称") + p_fetch = sub.add_parser("fetch", help="拉取/更新 catalog source") + p_fetch.add_argument("name", help="catalog 名称") p_fetch.set_defaults(func=cmd_fetch) - p_source = sub.add_parser("source", help="管理包含多个 skills 的自定义仓库") + p_source = sub.add_parser("source", help="管理自定义 Skill source") source_sub = p_source.add_subparsers(dest="source_command", required=True) p_source_add = source_sub.add_parser("add", help="注册 Git 或本地 skill source") @@ -1319,18 +1316,6 @@ def build_parser() -> argparse.ArgumentParser: ) p_source_remove.set_defaults(func=cmd_source_remove) - p_enable = sub.add_parser("enable", help=argparse.SUPPRESS) - p_enable.add_argument("name") - p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append") - p_enable.add_argument("--project") - p_enable.set_defaults(func=cmd_enable) - - p_disable = sub.add_parser("disable", help=argparse.SUPPRESS) - p_disable.add_argument("name") - p_disable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append") - p_disable.add_argument("--project") - p_disable.set_defaults(func=cmd_disable) - p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链") p_sync.add_argument("-a", "--agent", dest="agents", nargs="+", action="append") p_sync.add_argument("--project") diff --git a/skiff/paths.py b/skiff/paths.py index 11089b9..f891a20 100644 --- a/skiff/paths.py +++ b/skiff/paths.py @@ -9,8 +9,9 @@ SKILLS_HOME = HOME / ".skills" SKILLS_DIR = SKILLS_HOME / "skills" TEMPLATE_DIR = SKILLS_DIR / "_template" DRAFTS_DIR = SKILLS_HOME / ".drafts" -REGISTRY_FILE = SKILLS_HOME / "registry.yaml" -EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals" +CATALOG_FILE = SKILLS_HOME / "catalog.yaml" +LEGACY_REGISTRY_FILE = SKILLS_HOME / "registry.yaml" +CATALOG_CACHE_DIR = HOME / ".local" / "share" / "skills" / "externals" CONFIG_FILE = HOME / ".config" / "skiff" / "config.yaml" SOURCES_DIR = HOME / ".local" / "share" / "skiff" / "sources" PROJECT_MANIFEST = ".skills.yaml" diff --git a/skiff/project.py b/skiff/project.py index ff5a133..347bf01 100644 --- a/skiff/project.py +++ b/skiff/project.py @@ -7,7 +7,7 @@ from typing import Any from skiff import yaml_io from skiff.paths import PROJECT_MANIFEST -from skiff.skills import resolve_skill_source +from skiff.skills import normalize_source, resolve_skill_source def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]: @@ -31,11 +31,13 @@ def save_manifest(path: Path, data: dict[str, Any]) -> None: def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]: if isinstance(entry, str): - return {"name": entry, "source": "owned"} + return {"name": entry, "source": "builtin"} name = entry.get("name") if not name: raise SystemExit(f".skills.yaml 条目缺少 name: {entry}") - source = entry.get("source", "owned") + 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")}} @@ -44,8 +46,6 @@ def manifest_skill_names(data: dict[str, Any]) -> list[str]: def _entry_to_yaml(entry: dict[str, Any]) -> str | dict[str, Any]: - if entry.get("source", "owned") == "owned" and set(entry.keys()) <= {"name", "source"}: - return entry["name"] return entry @@ -53,7 +53,7 @@ def add_skill_to_manifest( manifest_path: Path, name: str, *, - source: str = "owned", + source: str = "builtin", extra: dict[str, Any] | None = None, ) -> None: path = manifest_path @@ -100,7 +100,7 @@ def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]: def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]: name = entry["name"] - source = entry.get("source", "owned") - if source == "registry" and entry.get("registry"): - source = f"registry:{entry['registry']}" + 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) diff --git a/skiff/skills.py b/skiff/skills.py index 51424c4..ebf155b 100644 --- a/skiff/skills.py +++ b/skiff/skills.py @@ -6,15 +6,19 @@ import re from pathlib import Path from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home -from skiff.registry import ( - external_collection_skill_path, - external_skill_path, - load_registry, +from skiff.catalog import ( + catalog_skill_path, + discover_catalog_skills, + load_catalog, +) +from skiff.sources import ( + discover_source_skills, + list_source_skills, + load_sources, ) -from skiff.sources import list_source_skills, load_sources, source_skills_root -def list_owned_skills() -> list[str]: +def list_builtin_skills() -> list[str]: ensure_skills_home() if not SKILLS_DIR.is_dir(): return [] @@ -29,19 +33,31 @@ def list_owned_skills() -> list[str]: return names -def owned_skill_path(name: str) -> Path: +def builtin_skill_path(name: str) -> Path: path = SKILLS_DIR / name if not (path / "SKILL.md").is_file(): - raise SystemExit(f"自研 skill 不存在: {name}") + 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, source + 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} 不一致" @@ -63,42 +79,36 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, ensure_skills_home() name, source = split_skill_spec(name, source) - owned = SKILLS_DIR / name - if source == "owned": - if not (owned / "SKILL.md").is_file(): - raise SystemExit(f"owned source 中找不到 skill: {name}") - return owned, "owned" + builtin = SKILLS_DIR / name + if source == "builtin": + if not (builtin / "SKILL.md").is_file(): + raise SystemExit(f"builtin source 中找不到 skill: {name}") + return builtin, "builtin" - registry = load_registry() + catalog = load_catalog() sources = load_sources() - registry_collection = None - if source and source.startswith("registry:"): - registry_collection = source.split(":", 1)[1] - elif ( - source - and source not in ("owned", "registry") - and source in registry - and source not in sources - ): - registry_collection = source - if registry_collection: - path = external_collection_skill_path( - registry_collection, - name, - registry[registry_collection], - ) - return path, f"registry:{registry_collection}" + 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 == "registry": - if name not in registry: - raise SystemExit(f"registry 中找不到 skill: {name}") - path = external_skill_path(name, registry[name]) + 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"外部 skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。" + f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。" f"请运行: skiff fetch {name}" ) - return path, "registry" + return path, f"catalog:{name}" if source: if source not in sources: @@ -106,28 +116,47 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, f"项目依赖 source {source!r},但本机尚未配置。" f"请运行: skiff source add {source} " ) - path = source_skills_root(source, sources[source]) / name - if not (path / "SKILL.md").is_file(): + skills = discover_source_skills(source, sources[source]) + if name not in skills: raise SystemExit(f"source {source!r} 中找不到 skill: {name}") - return path, source + return skills[name], source candidates: list[tuple[Path, str]] = [] - if (owned / "SKILL.md").is_file(): - candidates.append((owned, "owned")) - if name in registry: - candidates.append((external_skill_path(name, registry[name]), "registry")) + 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(): - path = source_skills_root(source_name, entry) / name - if (path / "SKILL.md").is_file(): - candidates.append((path, source_name)) + 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 == "registry" and not path.exists(): - raise SystemExit(f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}") + if resolved_source.startswith("catalog:") and not path.exists(): + provider = resolved_source.split(":", 1)[1] + raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: skiff fetch {provider}") return path, resolved_source raise SystemExit(f"找不到 skill: {name}") diff --git a/skiff/source-model.md b/skiff/source-model.md index 708889c..511836f 100644 --- a/skiff/source-model.md +++ b/skiff/source-model.md @@ -25,7 +25,7 @@ skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时 来源都可以包含一个或多个 Skill;除 builtin 外,catalog 和 custom 都可以使用 Git 仓库或本地目录。 -## 当前模型 +## 迁移前模型 ```mermaid flowchart TD @@ -46,7 +46,7 @@ flowchart TD R2 --> E ``` -当前实现中: +迁移前实现中: - `list` 和 `status` 支持 owned、registry 和 custom source。 - `resolve_skill_source` 可以解析三种来源并处理同名歧义。 @@ -54,7 +54,7 @@ flowchart TD - `.skills.yaml` 默认将未声明来源的 Skill 解释为 `owned`。 - custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。 -## 推荐模型 +## 现行模型 ```mermaid flowchart TD @@ -86,7 +86,7 @@ flowchart TD | 类型 | 含义 | 配置来源 | 用户界面展示 | | --- | --- | --- | --- | | `builtin` | 随当前 skiff 仓库提供 | `skills/` | `builtin` | -| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml`,迁移前为 `registry.yaml` | `catalog:` | +| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:` | | `custom` | 用户在本机显式注册的命名来源 | `~/.config/skiff/config.yaml` | `custom:` | `builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用 @@ -166,9 +166,9 @@ skills: custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以 独立配置仓库地址,而项目只依赖稳定的来源名称。 -## 兼容迁移 +## 兼容策略 -这是一次用户可见术语调整,应提供兼容层,避免已有项目立即失效: +用户可见术语已经调整,并保留以下读取兼容,避免已有项目立即失效: 1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:` 和 `custom:`。 @@ -177,22 +177,21 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。 4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。 5. 读取旧 manifest 中的 `source: registry` 和 `registry: `,归一化为 `catalog:`。 -6. `registry.yaml` 可以先保留文件名,仅将用户界面术语改为 catalog;单独迁移为 - `catalog.yaml` 时,应兼容读取旧文件。 +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 使用相同的父子展示逻辑。 -## 影响范围 +## 实现范围 -实施时预计涉及: +当前实现覆盖: - `skiff/skills.py`:来源解析、归一化和 builtin 命名。 - `skiff/project.py`:manifest 默认值、序列化与旧值兼容。 - `skiff/sources.py`:来源保留字。 -- `skiff/registry.py`:逐步重命名为 catalog 概念。 +- `skiff/catalog.py`:catalog 配置、checkout 与 Skill 发现。 - `skiff/cli.py`:`list`、`status`、`add`、`select` 和输出文案。 - `skiff/selector.py`:统一 catalog/custom collection 的父子展示。 - CLI 与来源解析测试。 @@ -213,7 +212,6 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。 ## 设计前提 -本方案假设 `registry.yaml` 当前的真实职责是维护 skiff 预置的来源目录,而不是提供 -远程发布、版本解析或可信签名等注册中心能力。因此推荐逐步将用户可见概念改为 -`catalog`。如果未来实现真正的远程 registry,再单独定义其协议和与 catalog 的同步 -关系,不复用当前含义模糊的名称。 +迁移前 `registry.yaml` 的真实职责只是维护 skiff 预置的来源目录,而不是提供远程 +发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来 +实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。 diff --git a/skiff/sources.py b/skiff/sources.py index 276ab02..56d6fb5 100644 --- a/skiff/sources.py +++ b/skiff/sources.py @@ -9,7 +9,7 @@ from typing import Any from skiff import yaml_io from skiff.paths import CONFIG_FILE, SOURCES_DIR -RESERVED_SOURCES = {"owned", "registry"} +RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"} def validate_source_name(name: str) -> None: @@ -68,15 +68,23 @@ def source_skills_root(name: str, entry: dict[str, Any]) -> Path: return root -def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]: +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 + 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() - ] + 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: diff --git a/skiff/symlinks.py b/skiff/symlinks.py index 6039c26..7a520c4 100644 --- a/skiff/symlinks.py +++ b/skiff/symlinks.py @@ -68,6 +68,9 @@ def find_repo_root(start: Path | None = None) -> Path | None: for directory in [start, *start.parents]: if (directory / ".skills.yaml").is_file(): return directory - if (directory / "skills").is_dir() and (directory / "registry.yaml").is_file(): + if (directory / "skills").is_dir() and ( + (directory / "catalog.yaml").is_file() + or (directory / "registry.yaml").is_file() + ): return directory return None diff --git a/skills/skiff/SKILL.md b/skills/skiff/SKILL.md index 9814a94..20a384e 100644 --- a/skills/skiff/SKILL.md +++ b/skills/skiff/SKILL.md @@ -15,7 +15,7 @@ SSOT 固定在 `~/.skills/skills//`。内容通过 **symlink** 分发到 ## 在项目中使用 skill -先浏览可用的 owned skill,再安装到当前项目: +先浏览可用的 builtin skill,再安装到当前项目: ```bash skiff add --list @@ -61,11 +61,11 @@ skiff finalize 1. 先记录最小证据:触发用户表达、使用的 skill 名称、实际结果、期望结果,以及能复现问题的必要项目上下文。 2. 判断归属: - - 通用工作流、触发条件或验证缺陷:回流 owned skill。 + - 通用工作流、触发条件或验证缺陷:回流 builtin skill。 - 仅当前项目成立的命令、路径、业务规则:留在项目文档或项目配置,不写回通用 skill。 - CLI 安装、软链或校验行为异常:修改 `~/.skills/skiff/` 中的 CLI 和测试。 - - 第三方 skill:不要复制成 owned skill 或直接改安装目录;整理证据反馈上游,除非用户明确决定维护 fork。 -3. 确认真实来源。Agent 目录通常是软链,owned skill 的 SSOT 固定为: + - 第三方 skill:不要复制成 builtin skill 或直接改安装目录;整理证据反馈上游,除非用户明确决定维护 fork。 +3. 确认真实来源。Agent 目录通常是软链,builtin skill 的 SSOT 固定为: ```text ~/.skills/skills// @@ -108,7 +108,7 @@ skiff add discussion-notes -a cursor -g -y skiff add discussion-notes -a cursor -a codex -g -y ``` -registry 条目既可以指向单个 skill,也可以指向包含多个 skill 目录的 +catalog source既可以指向单个 skill,也可以指向包含多个 skill 目录的 collection。安装 collection 全部内容或其中一个: ```bash @@ -182,4 +182,4 @@ ACK 配置、检查接入状态或 - 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.skills/skills/` 再 `publish` - 未完成的内容保留在 `~/.skills/.drafts/`,不要直接放进正式 `skills/` - symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容 -- 社区 skill 用 `npx skills add`,不要用 skiff `registry add` 除非团队要 pin 版本 +- 社区 skill 用 `npx skills add`,不要用 skiff `catalog add` 除非团队要 pin 版本 diff --git a/tests/test_registry_collections.py b/tests/test_catalog_collections.py similarity index 78% rename from tests/test_registry_collections.py rename to tests/test_catalog_collections.py index 12f32f5..7095da0 100644 --- a/tests/test_registry_collections.py +++ b/tests/test_catalog_collections.py @@ -9,7 +9,8 @@ import unittest from pathlib import Path from unittest.mock import patch -from skiff.registry import external_checkout_path, registry_repo +from skiff import catalog +from skiff.catalog import catalog_checkout_path, catalog_repo REPO_ROOT = Path(__file__).resolve().parents[1] @@ -26,7 +27,16 @@ def write_skill(root: Path, name: str) -> Path: return skill -class RegistryCollectionTests(unittest.TestCase): +class CatalogCollectionTests(unittest.TestCase): + def test_load_catalog_falls_back_to_legacy_registry_file(self) -> None: + legacy = self.home / "legacy-registry.yaml" + legacy.write_text("legacy:\n repo: https://example.test/legacy.git\n") + with ( + patch.object(catalog, "CATALOG_FILE", self.home / "missing-catalog.yaml"), + patch.object(catalog, "LEGACY_REGISTRY_FILE", legacy), + ): + self.assertIn("legacy", catalog.load_catalog()) + def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.home = Path(self.temp_dir.name) @@ -58,7 +68,7 @@ class RegistryCollectionTests(unittest.TestCase): check=True, capture_output=True, ) - self.skills_home.joinpath("registry.yaml").write_text( + self.skills_home.joinpath("catalog.yaml").write_text( "test-pack:\n" f" repo: {self.upstream}\n" " ref: main\n" @@ -82,14 +92,14 @@ class RegistryCollectionTests(unittest.TestCase): check=False, ) - def test_registry_repo_expands_home_relative_local_path(self) -> None: + def test_catalog_repo_expands_home_relative_local_path(self) -> None: with patch.dict(os.environ, {"HOME": str(self.home)}): self.assertEqual( - registry_repo({"repo": "~/.skills"}), + catalog_repo({"repo": "~/.skills"}), str(self.skills_home), ) self.assertEqual( - external_checkout_path("skills", {"repo": "~/.skills"}), + catalog_checkout_path("skills", {"repo": "~/.skills"}), self.skills_home.resolve(), ) @@ -113,7 +123,23 @@ class RegistryCollectionTests(unittest.TestCase): self.assertTrue((skill_dir / "second-skill").is_symlink()) self.assertTrue((skill_dir / "second-skill" / "SKILL.md").is_file()) - def test_add_collection_with_registry_source_installs_all(self) -> None: + def test_add_collection_with_catalog_source_installs_all(self) -> None: + result = self.run_skiff( + "add", + "test-pack", + "--source", + "catalog", + "-g", + "-a", + "codex", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + skill_dir = self.home / ".codex" / "skills" + self.assertTrue((skill_dir / "first-skill").is_symlink()) + self.assertTrue((skill_dir / "second-skill").is_symlink()) + + def test_legacy_registry_source_alias_still_installs(self) -> None: result = self.run_skiff( "add", "test-pack", @@ -125,9 +151,9 @@ class RegistryCollectionTests(unittest.TestCase): ) self.assertEqual(result.returncode, 0, result.stderr) - skill_dir = self.home / ".codex" / "skills" - self.assertTrue((skill_dir / "first-skill").is_symlink()) - self.assertTrue((skill_dir / "second-skill").is_symlink()) + self.assertTrue( + (self.home / ".codex" / "skills" / "first-skill").is_symlink() + ) def test_project_add_records_collection_for_sync(self) -> None: project = self.home / "project" @@ -153,7 +179,7 @@ class RegistryCollectionTests(unittest.TestCase): ) self.assertEqual(added.returncode, 0, added.stderr) - self.assertIn('registry: "test-pack"', project.joinpath(".skills.yaml").read_text()) + self.assertIn('source: "catalog:test-pack"', project.joinpath(".skills.yaml").read_text()) self.assertEqual(synced.returncode, 0, synced.stderr) self.assertTrue(link.is_symlink()) self.assertTrue((link / "SKILL.md").is_file()) @@ -184,9 +210,9 @@ class RegistryCollectionTests(unittest.TestCase): self.assertEqual(installed.returncode, 0, installed.stderr) self.assertEqual(status.returncode, 0, status.stderr) - self.assertIn("[registry:test-pack] first-skill", status.stdout) - self.assertIn("[registry:test-pack] second-skill", status.stdout) - self.assertNotIn("[registry] test-pack\n (未 fetch)", status.stdout) + self.assertIn("[catalog:test-pack] first-skill", status.stdout) + self.assertIn("[catalog:test-pack] second-skill", status.stdout) + self.assertNotIn("[catalog] test-pack\n (未 fetch)", status.stdout) if __name__ == "__main__": diff --git a/tests/test_custom_sources.py b/tests/test_custom_sources.py index abf4a63..e824e9e 100644 --- a/tests/test_custom_sources.py +++ b/tests/test_custom_sources.py @@ -8,6 +8,7 @@ import unittest from pathlib import Path from unittest.mock import patch +from skiff.project import normalize_skill_entry from skiff.sources import fetch_source REPO_ROOT = Path(__file__).resolve().parents[1] @@ -25,12 +26,24 @@ def write_skill(root: Path, name: str) -> Path: class CustomSourceTests(unittest.TestCase): + def test_legacy_manifest_sources_normalize_to_new_model(self) -> None: + self.assertEqual( + normalize_skill_entry({"name": "ack", "source": "owned"})["source"], + "builtin", + ) + self.assertEqual( + normalize_skill_entry( + {"name": "think", "source": "registry", "registry": "waza"} + )["source"], + "catalog:waza", + ) + def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.home = Path(self.temp_dir.name) self.skills_home = self.home / ".skills" (self.skills_home / "skills").mkdir(parents=True) - (self.skills_home / "registry.yaml").write_text("", encoding="utf-8") + (self.skills_home / "catalog.yaml").write_text("", encoding="utf-8") def tearDown(self) -> None: self.temp_dir.cleanup() @@ -74,6 +87,36 @@ class CustomSourceTests(unittest.TestCase): config = self.home / ".config" / "skiff" / "config.yaml" self.assertIn("company:", config.read_text(encoding="utf-8")) + def test_local_source_can_expose_a_single_skill(self) -> None: + source = self.home / "single-source" + source.mkdir() + source.joinpath("SKILL.md").write_text( + "---\nname: solo\ndescription: Test single source.\n---\n", + encoding="utf-8", + ) + added = self.run_skiff( + "source", + "add", + "solo", + "--local", + str(source), + "--skills-path", + ".", + ) + installed = self.run_skiff( + "add", + "solo", + "-g", + "-a", + "codex", + ) + + self.assertEqual(added.returncode, 0, added.stderr) + self.assertEqual(installed.returncode, 0, installed.stderr) + self.assertEqual( + (self.home / ".codex" / "skills" / "solo").resolve(), + source.resolve(), + ) def test_project_add_persists_resolved_source_in_manifest(self) -> None: company = self.home / "company" expected = write_skill(company / "skills", "code-review") @@ -136,17 +179,17 @@ class CustomSourceTests(unittest.TestCase): result = self.run_skiff("add", "code-review", "-g", "-a", "codex") self.assertNotEqual(result.returncode, 0) - self.assertIn("owned/code-review", result.stderr) + self.assertIn("builtin/code-review", result.stderr) self.assertIn("company/code-review", result.stderr) - def test_custom_source_namespace_is_not_shadowed_by_registry_collection(self) -> None: + def test_custom_source_namespace_is_not_shadowed_by_catalog_collection(self) -> None: company = self.home / "company" expected = write_skill(company / "skills", "code-review") - registry_repo = self.home / "registry-repo" - write_skill(registry_repo / "skills", "other-skill") - self.skills_home.joinpath("registry.yaml").write_text( + catalog_repo = self.home / "catalog-repo" + write_skill(catalog_repo / "skills", "other-skill") + self.skills_home.joinpath("catalog.yaml").write_text( "company:\n" - f" repo: {registry_repo}\n" + f" repo: {catalog_repo}\n" " ref: main\n" " path: skills\n", encoding="utf-8", diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 40126c2..c2825d9 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -36,7 +36,7 @@ class InstallScriptTests(unittest.TestCase): link = home / relative self.assertTrue(link.is_symlink(), relative) self.assertEqual(link.resolve(), (REPO_ROOT / "skills" / "skiff").resolve()) - self.assertIn("已安装项目 skill", result.stdout) + self.assertIn("已全局安装 builtin skiff skill", result.stdout) if __name__ == "__main__": diff --git a/tests/test_select.py b/tests/test_select.py index 3910cf6..eb03209 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -12,7 +12,7 @@ from unittest.mock import Mock, patch from skiff import cli from skiff import yaml_io -from skiff.registry import external_repo_path, external_skill_path +from skiff.catalog import catalog_repo_path, catalog_skill_path from skiff.selector import SkillChoice, filter_choices, fit_to_width, select_skills @@ -28,7 +28,7 @@ class SelectorTests(unittest.TestCase): data = { "skills": [ { - "name": "external-one", + "name": "catalog-one", "source": "registry", "targets": ["codex"], } @@ -39,24 +39,24 @@ class SelectorTests(unittest.TestCase): def test_filter_matches_name_kind_and_description(self) -> None: choices = [ - SkillChoice("frontend-design", "external", "创建界面"), + SkillChoice("frontend-design", "catalog", "创建界面"), SkillChoice( "discussion-notes", - "owned", + "builtin", "维护讨论笔记", readonly_status="全局: codex", ), ] self.assertEqual([c.name for c in filter_choices(choices, "front")], ["frontend-design"]) - self.assertEqual([c.name for c in filter_choices(choices, "owned")], ["discussion-notes"]) + self.assertEqual([c.name for c in filter_choices(choices, "builtin")], ["discussion-notes"]) self.assertEqual([c.name for c in filter_choices(choices, "界面")], ["frontend-design"]) self.assertEqual([c.name for c in filter_choices(choices, "codex")], ["discussion-notes"]) def test_selector_preserves_preselected_items(self) -> None: choices = [ - SkillChoice("already-there", "owned", installed=True), - SkillChoice("new-skill", "external"), + SkillChoice("already-there", "builtin", installed=True), + SkillChoice("new-skill", "catalog"), ] screen = Mock() screen.getmaxyx.return_value = (24, 100) @@ -76,7 +76,7 @@ class SelectorTests(unittest.TestCase): choices = [ SkillChoice( "ack", - "owned", + "builtin", installed=False, readonly_status="全局: cursor,claude,codex", ) @@ -108,7 +108,7 @@ class SelectorTests(unittest.TestCase): choices = [ SkillChoice( "ack", - "owned", + "builtin", description="初始化、检查并运行 ACK", readonly_status="全局: codex", ) @@ -128,7 +128,7 @@ class SelectorTests(unittest.TestCase): ) self.assertEqual( "".join(call.args[2] for call in title_calls), - "[ ] ack owned 全局: codex", + "[ ] ack builtin 全局: codex", ) self.assertEqual(description_call.args[2], " 初始化、检查并运行 ACK") name_call = next(call for call in title_calls if call.args[2] == "ack") @@ -142,8 +142,8 @@ class SelectorTests(unittest.TestCase): "repository", children=("waza/think", "waza/ui"), ), - SkillChoice("waza/think", "external:waza", indent=1), - SkillChoice("waza/ui", "external:waza", indent=1), + SkillChoice("waza/think", "catalog:waza", indent=1), + SkillChoice("waza/ui", "catalog:waza", indent=1), ] screen = Mock() screen.getmaxyx.return_value = (24, 100) @@ -161,8 +161,8 @@ class SelectorTests(unittest.TestCase): "repository", children=("waza/think", "waza/ui"), ), - SkillChoice("waza/think", "external:waza", installed=True, indent=1), - SkillChoice("waza/ui", "external:waza", indent=1), + SkillChoice("waza/think", "catalog:waza", installed=True, indent=1), + SkillChoice("waza/ui", "catalog:waza", indent=1), ] screen = Mock() screen.getmaxyx.return_value = (24, 100) @@ -182,17 +182,17 @@ class SelectorTests(unittest.TestCase): first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"} second = {"repo": "https://example.test/skills.git", "ref": "main", "path": "b"} - self.assertEqual(external_repo_path(first), external_repo_path(second)) + self.assertEqual(catalog_repo_path(first), catalog_repo_path(second)) - def test_external_skill_path_rejects_checkout_escape(self) -> None: + def test_catalog_skill_path_rejects_checkout_escape(self) -> None: entry = { "repo": "https://example.test/skills.git", "ref": "main", "path": "../../outside", } - with self.assertRaisesRegex(SystemExit, "超出外部仓库"): - external_skill_path("unsafe-skill", entry) + with self.assertRaisesRegex(SystemExit, "超出来源仓库"): + catalog_skill_path("unsafe-skill", entry) def test_collection_discovery_ignores_symlinked_skill(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -210,12 +210,12 @@ class SelectorTests(unittest.TestCase): "path": "skills", } - with patch("skiff.registry.external_checkout_path", return_value=checkout): - from skiff.registry import discover_external_skills + with patch("skiff.catalog.catalog_checkout_path", return_value=checkout): + from skiff.catalog import discover_catalog_skills - self.assertEqual(discover_external_skills("unsafe", entry), {}) + self.assertEqual(discover_catalog_skills("unsafe", entry), {}) - def test_registry_clone_is_shallow(self) -> None: + def test_catalog_clone_is_shallow(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) checkout = root / "checkout" @@ -226,16 +226,16 @@ class SelectorTests(unittest.TestCase): "path": "skills", } with ( - patch.object(cli, "external_skill_path", return_value=skill_root), - patch.object(cli, "external_checkout_path", return_value=checkout), + patch.object(cli, "catalog_skill_path", return_value=skill_root), + patch.object(cli, "catalog_checkout_path", return_value=checkout), patch.object( cli, - "discover_external_skills", + "discover_catalog_skills", side_effect=[{}, {"demo": skill_root / "demo"}], ), patch.object(cli.subprocess, "run") as run, ): - cli._ensure_registry_fetched("demo-pack", entry) + cli._ensure_catalog_fetched("demo-pack", entry) run.assert_called_once_with( [ @@ -277,9 +277,9 @@ class SelectCommandTests(unittest.TestCase): def test_project_selection_installs_new_and_records_all_selected(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) - external = project / "external-one" - external.mkdir() - external.joinpath("SKILL.md").write_text("---\n", encoding="utf-8") + catalog = project / "catalog-one" + catalog.mkdir() + catalog.joinpath("SKILL.md").write_text("---\n", encoding="utf-8") args = argparse.Namespace( agents=[["codex"]], global_scope=False, @@ -301,28 +301,28 @@ class SelectCommandTests(unittest.TestCase): ) -> set[str]: selected_choices.extend(choices) selected_scope.append(scope_label) - return {"owned-one", "external-one"} + return {"builtin-one", "catalog-one"} with ( patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdout", stdout), patch.object(cli, "ensure_skills_home"), - patch.object(cli, "list_owned_skills", return_value=["owned-one"]), - patch.object(cli, "skill_description", return_value="owned"), + patch.object(cli, "list_builtin_skills", return_value=["builtin-one"]), + patch.object(cli, "skill_description", return_value="builtin"), patch.object( cli, - "load_registry", + "load_catalog", return_value={ - "external-one": { + "catalog-one": { "repo": "https://example.test/skills.git", "ref": "main", - "path": "external-one", + "path": "catalog-one", } }, ), - patch.object(cli, "_registry_skill_names", return_value=["external-one"]), - patch.object(cli, "external_skill_path", return_value=external), - patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]), + patch.object(cli, "_catalog_skill_names", return_value=["catalog-one"]), + patch.object(cli, "catalog_skill_path", return_value=catalog), + patch.object(cli, "_list_fully_installed_names", return_value=["builtin-one"]), patch.object( cli, "_global_installation_note", @@ -331,7 +331,7 @@ class SelectCommandTests(unittest.TestCase): patch.object( cli, "_is_fully_installed", - side_effect=lambda name, expected, project_root, targets: name == "owned-one", + side_effect=lambda name, expected, project_root, targets: name == "builtin-one", ), patch.object( cli, @@ -348,10 +348,11 @@ class SelectCommandTests(unittest.TestCase): manifest = (project / ".skills.yaml").read_text(encoding="utf-8") - self.assertEqual(installed, ["external-one"]) - self.assertIn("owned-one", manifest) - self.assertIn('name: "external-one"', manifest) - self.assertIn("source: registry", manifest) + self.assertEqual(installed, ["catalog-one"]) + self.assertIn("builtin-one", manifest) + self.assertIn("source: builtin", manifest) + self.assertIn('name: "catalog-one"', manifest) + self.assertIn('source: "catalog:catalog-one"', manifest) self.assertIn("targets:", manifest) self.assertIn("codex", manifest) self.assertEqual( @@ -389,7 +390,7 @@ class SelectCommandTests(unittest.TestCase): self.assertEqual(note, "全局: cursor;全局同名冲突: claude") - def test_select_rejects_invalid_registry_name_before_rendering(self) -> None: + def test_select_rejects_invalid_catalog_name_before_rendering(self) -> None: args = argparse.Namespace( agents=None, global_scope=True, @@ -405,10 +406,10 @@ class SelectCommandTests(unittest.TestCase): patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdout", stdout), patch.object(cli, "ensure_skills_home"), - patch.object(cli, "list_owned_skills", return_value=[]), + patch.object(cli, "list_builtin_skills", return_value=[]), patch.object( cli, - "load_registry", + "load_catalog", return_value={"../../victim": {"repo": "https://example.test/repo.git"}}, ), patch.object(cli, "select_skills") as selector, @@ -418,7 +419,7 @@ class SelectCommandTests(unittest.TestCase): selector.assert_not_called() - def test_select_expands_registry_collection_choices(self) -> None: + def test_select_expands_catalog_collection_choices(self) -> None: args = argparse.Namespace( agents=[["codex"]], global_scope=True, @@ -440,10 +441,10 @@ class SelectCommandTests(unittest.TestCase): patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdout", stdout), patch.object(cli, "ensure_skills_home"), - patch.object(cli, "list_owned_skills", return_value=[]), + patch.object(cli, "list_builtin_skills", return_value=[]), patch.object( cli, - "load_registry", + "load_catalog", return_value={ "waza": { "repo": "https://example.test/waza.git", @@ -454,7 +455,7 @@ class SelectCommandTests(unittest.TestCase): ), patch.object( cli, - "_registry_skill_names", + "_catalog_skill_names", return_value=["think", "ui"], ), patch.object(cli, "_list_fully_installed_names", return_value=[]), @@ -476,9 +477,72 @@ class SelectCommandTests(unittest.TestCase): ) self.assertEqual(selected_choices[0].children, ("waza/think", "waza/ui")) self.assertEqual([choice.indent for choice in selected_choices[1:]], [1, 1]) - self.assertEqual(installed, [("think", "registry:waza")]) + self.assertEqual(installed, [("think", "catalog:waza")]) - def test_select_deduplicates_local_registry_collection_from_owned(self) -> None: + def test_select_expands_custom_collection_choices(self) -> None: + args = argparse.Namespace( + agents=[["codex"]], + global_scope=True, + project=None, + yes=False, + ) + stdin = Mock() + stdout = Mock() + stdin.isatty.return_value = True + stdout.isatty.return_value = True + selected_choices: list[SkillChoice] = [] + installed: list[tuple[str, str | None]] = [] + + def choose(choices: list[SkillChoice], **_: object) -> set[str]: + selected_choices.extend(choices) + return {"company/review"} + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + review = root / "review" + release = root / "release" + review.mkdir() + release.mkdir() + with ( + patch.object(cli.sys, "stdin", stdin), + patch.object(cli.sys, "stdout", stdout), + patch.object(cli, "ensure_skills_home"), + patch.object(cli, "list_builtin_skills", return_value=[]), + patch.object(cli, "load_catalog", return_value={}), + patch.object( + cli, + "load_sources", + return_value={"company": {"local_path": str(root)}}, + ), + patch.object(cli, "_ensure_source_fetched"), + patch.object( + cli, + "discover_source_skills", + return_value={"review": review, "release": release}, + ), + patch.object(cli, "_is_fully_installed", return_value=False), + patch.object(cli, "select_skills", side_effect=choose), + patch.object( + cli, + "_install_skill", + side_effect=lambda name, targets, project_root, **kwargs: installed.append( + (name, kwargs.get("source")) + ), + ), + ): + cli.cmd_select(args) + + self.assertEqual( + [choice.name for choice in selected_choices], + ["company", "company/review", "company/release"], + ) + self.assertEqual( + selected_choices[0].children, + ("company/review", "company/release"), + ) + self.assertEqual(installed, [("review", "company")]) + + def test_select_deduplicates_local_catalog_collection_from_builtin(self) -> None: args = argparse.Namespace( agents=[["codex"]], global_scope=True, @@ -504,11 +568,11 @@ class SelectCommandTests(unittest.TestCase): patch.object(cli.sys, "stdout", stdout), patch.object(cli, "SKILLS_DIR", skills_root), patch.object(cli, "ensure_skills_home"), - patch.object(cli, "list_owned_skills", return_value=["ack"]), - patch.object(cli, "skill_description", return_value="owned ack"), + patch.object(cli, "list_builtin_skills", return_value=["ack"]), + patch.object(cli, "skill_description", return_value="builtin ack"), patch.object( cli, - "load_registry", + "load_catalog", return_value={ "skills": { "repo": "~/.skills", @@ -517,8 +581,8 @@ class SelectCommandTests(unittest.TestCase): } }, ), - patch.object(cli, "_registry_skill_names", return_value=["ack"]), - patch.object(cli, "external_skill_path", return_value=skills_root), + patch.object(cli, "_catalog_skill_names", return_value=["ack"]), + patch.object(cli, "catalog_skill_path", return_value=skills_root), patch.object(cli, "_is_fully_installed", return_value=False), patch.object(cli, "select_skills", side_effect=choose), ): @@ -540,7 +604,7 @@ class SelectCommandTests(unittest.TestCase): with ( patch.object(cli, "_ensure_source_fetched"), - patch.object(cli, "resolve_skill_source", return_value=(skill, "owned")), + patch.object(cli, "resolve_skill_source", return_value=(skill, "builtin")), patch.object( cli, "agent_skill_dir",