refactor: unify skill source model

This commit is contained in:
2026-07-30 12:11:25 +08:00
parent 076d87e303
commit 7d1994cf93
18 changed files with 669 additions and 527 deletions
+21 -21
View File
@@ -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/<name>/ ←── skiff install / enable
skiff/ ←── python3 -m skiff
registry.yaml ←── skiff add / fetch
catalog.yaml ←── skiff add / fetch
~/.skillssymlink
@@ -155,8 +155,8 @@ registry.yaml ←── skiff add / fetch
| 层级 | 位置 | 维护方式 |
| ---------------- | ---------------------------------- | ------------------------------- |
| **Owned** | `skills/<name>/` | 本仓库 commit |
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
| **Builtin** | `skills/<name>/` | 本仓库 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 <name>` | 全局安装(symlink |
| `skiff uninstall <name>` | 移除 symlink |
| `skiff add / fetch / install-external` | 外部 Git skill |
| `skiff add <name> [-g]` | 项目或全局安装(symlink |
| `skiff remove <name> [-g]` | 移除 symlink |
| `skiff catalog add` / `skiff fetch` | 管理和拉取 catalog source |
### 草稿与健康检查
+24 -17
View File
@@ -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/ # 自研 skillSSOT):每个子目录必须有 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 <name> # 安装到 ~/.cursor/skills/ 等
skiff install <name> --target cursor
skiff add <name> -g # 安装到 ~/.cursor/skills/ 等
skiff add <name> -g -a cursor
```
### 项目级
@@ -75,10 +75,10 @@ skiff install <name> --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/<name>/ ←── skiff install / enable
├── registry.yaml ←── skiff add / fetch
├── catalog.yaml ←── 预置来源发现与 fetch
└── skiff/ ←── python3 -m skiff
~/.skillssymlink
+1 -10
View File
@@ -1,4 +1,4 @@
# External skills registry
# Preconfigured Skill source catalog
# Format:
# <name>:
# repo: <git-url>
@@ -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
+16 -16
View File
@@ -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 <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
### 全局安装(自研 skill
### Skill 安装
| 命令 | 说明 |
|------|------|
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
| `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
| `skiff remove <name> [--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 <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
| `skiff fetch <name>` | 克隆或更新外部仓库缓存 |
| `skiff add <name> [-g] [-a AGENT...]` | 安装 registry 中的单个 skill 或完整 collection(缺失时自动 fetch |
| `skiff catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
| `skiff fetch <name>` | 克隆或更新 catalog source checkout |
| `skiff add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch |
| `skiff add <collection>/<skill> [...]` | 只安装 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 <name>`
@@ -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 |
+1 -1
View File
@@ -1,3 +1,3 @@
"""skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.5.0"
__version__ = "0.6.0"
+26 -39
View File
@@ -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]
+233 -248
View File
@@ -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,29 +742,11 @@ 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":
extra = {"targets": entry_targets} if entry_targets else None
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",
source=source,
extra=extra,
)
@@ -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 <name>")
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 <name>")
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")
+3 -2
View File
@@ -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"
+9 -9
View File
@@ -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)
+78 -49
View File
@@ -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],
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 path, f"registry:{registry_collection}"
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} <repo>"
)
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}")
+13 -15
View File
@@ -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:<name>` |
| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/skiff/config.yaml` | `custom:<name>` |
`builtin``owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
@@ -166,9 +166,9 @@ skills:
custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以
独立配置仓库地址,而项目只依赖稳定的来源名称。
## 兼容迁移
## 兼容策略
这是一次用户可见术语调整,应提供兼容,避免已有项目立即失效:
用户可见术语已经调整,并保留以下读取兼容,避免已有项目立即失效:
1. 对外文档、CLI 输出和 selector 统一使用 `builtin``catalog:<name>`
`custom:<name>`
@@ -177,22 +177,21 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`
5. 读取旧 manifest 中的 `source: registry``registry: <name>`,归一化为
`catalog:<name>`
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 的同步关系,不复用旧名称。
+15 -7
View File
@@ -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:
+4 -1
View File
@@ -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
+6 -6
View File
@@ -15,7 +15,7 @@ SSOT 固定在 `~/.skills/skills/<name>/`。内容通过 **symlink** 分发到
## 在项目中使用 skill
先浏览可用的 owned skill,再安装到当前项目:
先浏览可用的 builtin skill,再安装到当前项目:
```bash
skiff add --list
@@ -61,11 +61,11 @@ skiff finalize <name>
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/<name>/
@@ -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 版本
@@ -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__":
+50 -7
View File
@@ -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",
+1 -1
View File
@@ -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__":
+122 -58
View File
@@ -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",