refactor: unify skill source model
This commit is contained in:
+16
-16
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
@@ -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]
|
||||
+238
-253
@@ -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 <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
@@ -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
@@ -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)
|
||||
|
||||
+79
-50
@@ -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} <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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user