4 Commits

Author SHA1 Message Date
laily 983183d5b3 feat: support registry collections and docker publishing 2026-07-27 23:25:46 +08:00
laily b78126830b merge: custom skill sources
# Conflicts:
#	skiff/README.md
#	skiff/cli.py
#	skiff/project.py
2026-07-27 18:11:31 +08:00
laily 3411e54b13 feat: support custom skill sources 2026-07-27 18:09:13 +08:00
laily b9963b66de feat: add interactive skill selector 2026-07-27 18:07:39 +08:00
20 changed files with 2085 additions and 75 deletions
+25
View File
@@ -97,6 +97,28 @@ skiff fetch superpowers
skiff install-external superpowers skiff install-external superpowers
``` ```
### 自定义仓库(Custom Sources)
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
```bash
skiff source add company \
git@git.company.com:platform/agent-skills.git \
--skills-path skills
skiff list --source company
skiff add company/internal-review -g
```
也可以接入已有本地 checkout:
```bash
skiff source add company --local ~/code/company-skills --skills-path skills
```
配置保存在 `~/.config/skiff/config.yaml`,Git source 默认 clone 到
`~/.local/share/skiff/sources/<source>/`。项目 `.skills.yaml` 只记录逻辑
source 名称,每台机器独立配置实际仓库地址。
### 社区(External NPM / GitHub) ### 社区(External NPM / GitHub)
推荐使用 Vercel CLI 安装第三方 skill: 推荐使用 Vercel CLI 安装第三方 skill:
@@ -143,6 +165,7 @@ registry.yaml ←── skiff add / fetch
| ---------------- | ---------------------------------- | ------------------------------- | | ---------------- | ---------------------------------- | ------------------------------- |
| **Owned** | `skills/<name>/` | 本仓库 commit | | **Owned** | `skills/<name>/` | 本仓库 commit |
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` | | **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
| **Custom Source** | `~/.local/share/skiff/sources/` 或本地路径 | `skiff source add/fetch` |
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` | | **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
### 非 Skill 资料分类 ### 非 Skill 资料分类
@@ -176,6 +199,8 @@ skills:
- name: superpowers - name: superpowers
source: registry source: registry
ref: main ref: main
- name: internal-review
source: company
targets: # 可选,默认 all targets: # 可选,默认 all
- cursor - cursor
+1
View File
@@ -10,6 +10,7 @@ cd ~/.skills
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent ./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
skiff install declarative-openspec-loop skiff install declarative-openspec-loop
skiff select # 交互式选择并批量安装
skiff list skiff list
skiff status skiff status
``` ```
+20 -1
View File
@@ -4,9 +4,28 @@
# repo: <git-url> # repo: <git-url>
# ref: <branch|tag> (default: main) # ref: <branch|tag> (default: main)
# path: <subpath> (default: .) # path: <subpath> (default: .)
# description: <text> (optional, shown by `skiff select`)
# tags: (optional)
# - <tag>
# #
# Example: # Single-skill example (`path` contains SKILL.md):
# example-skills: # example-skills:
# repo: https://github.com/example/skills # repo: https://github.com/example/skills
# ref: main # ref: main
# path: . # path: .
#
# Collection example (`path` contains skill directories):
# example-collection:
# repo: https://github.com/example/skill-collection
# ref: main
# path: skills
waza:
repo: https://github.com/tw93/Waza.git
ref: main
path: skills
description: Waza engineering workflow skills for planning, UI, review, debugging, writing, research, reading, and agent health.
tags:
- engineering
- workflow
- skill-collection
+67 -5
View File
@@ -13,7 +13,8 @@ cd /path/to/skills # 本仓库根目录
## 命令风格 ## 命令风格
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,专用于 **~/.skills 自研 skill**。 接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
统一管理 **~/.skills 自研 skill**、命名 custom source 和 registry 外部 skill。
```bash ```bash
# 浏览可用自研 skill # 浏览可用自研 skill
@@ -56,7 +57,7 @@ skiff bootstrap
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `skiff list` | 列出自研 skill 与 registry 中的外部 skill | | `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 | | `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
### 项目初始化 ### 项目初始化
@@ -70,6 +71,7 @@ skiff bootstrap
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) | | `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
| `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
| `skiff remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) | | `skiff remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
| `skiff add --list` | 列出可用自研 skill | | `skiff add --list` | 列出可用自研 skill |
| `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push | | `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push |
@@ -89,8 +91,65 @@ skiff bootstrap
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `skiff registry add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` | | `skiff registry add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
| `skiff fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` | | `skiff fetch <name>` | 克隆或更新外部仓库缓存 |
| `skiff add <name> [-g] [-a AGENT...]` | 安装 registry 中的外部 skill(缺失时自动 fetch) | | `skiff add <name> [-g] [-a AGENT...]` | 安装 registry 中的单个 skill 或完整 collection(缺失时自动 fetch) |
| `skiff add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
`registry.yaml` 条目可额外提供 `description` 和 `tags`。`description`
会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add <name>`
安装全部,`skiff select` 则展开为 `<name>/<skill>` 供分别勾选。同一
`repo` 与 `ref` 共享一份 Git checkout。
### 交互式批量安装
```bash
skiff select # 当前项目,全部 Agent
skiff select -a codex # 当前项目,仅 Codex
skiff select -g # 全局安装
skiff select --project ~/code/app # 指定项目
```
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
取消。已经安装到目标范围的 skill 默认勾选;取消勾选不会卸载已有 skill。
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
### Custom source(多-skill 仓库)
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
```bash
# Git 仓库,默认 clone 到 ~/.local/share/skiff/sources/company
skiff source add company \
git@git.company.com:platform/agent-skills.git \
--ref main \
--skills-path internal/skills
# 或接入已有本地仓库
skiff source add company \
--local ~/code/company-agent-skills \
--skills-path skills
skiff source list
skiff source fetch company
skiff list --source company
skiff add company/code-review -g -a codex
```
| 命令 | 说明 |
|------|------|
| `skiff source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
| `skiff source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
| `skiff source list` / `show <name>` | 查看 source |
| `skiff source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
| `skiff source remove <name>` | 移除配置并保留 checkout |
配置保存在 `~/.config/skiff/config.yaml`。Git/SSH 认证复用本机 Git 配置,
skiff 不保存 token。可以使用 `company/code-review`,也可以使用
`skiff add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
### 项目级 ### 项目级
@@ -156,6 +215,7 @@ skiff/
├── paths.py # 路径常量与 Agent 目标 ├── paths.py # 路径常量与 Agent 目标
├── skills.py # 自研 skill 发现 ├── skills.py # 自研 skill 发现
├── registry.py # registry.yaml 读写 ├── registry.py # registry.yaml 读写
├── sources.py # custom source 配置、发现与 Git 管理
├── project.py # .skills.yaml 管理 ├── project.py # .skills.yaml 管理
├── symlinks.py # 软链创建/检查/修复 ├── symlinks.py # 软链创建/检查/修复
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖) └── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
@@ -170,7 +230,9 @@ skiff/
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) | | `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 | | `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 | | `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库 | | `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库;新条目按 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。""" """skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.4.0" __version__ = "0.5.0"
+631 -49
View File
@@ -16,6 +16,7 @@ from skiff.paths import (
ALL_TARGETS, ALL_TARGETS,
DRAFTS_DIR, DRAFTS_DIR,
EXTERNALS_DIR, EXTERNALS_DIR,
CONFIG_FILE,
SKILLS_DIR, SKILLS_DIR,
SKILLS_HOME, SKILLS_HOME,
TEMPLATE_DIR, TEMPLATE_DIR,
@@ -30,15 +31,33 @@ from skiff.project import (
resolve_manifest_skill, resolve_manifest_skill,
save_manifest, save_manifest,
) )
from skiff.registry import external_skill_path, load_registry, save_registry from skiff.registry import (
discover_external_skills,
external_checkout_path,
external_skill_path,
load_registry,
save_registry,
)
from skiff.selector import SkillChoice, select_skills
from skiff.skills import ( from skiff.skills import (
list_custom_skills,
list_owned_skills, list_owned_skills,
owned_skill_path, owned_skill_path,
read_skill_meta,
resolve_skill_source, resolve_skill_source,
split_skill_spec,
skill_description, skill_description,
validate_skill_dir, validate_skill_dir,
validate_skill_name, validate_skill_name,
) )
from skiff.sources import (
fetch_source,
load_sources,
save_sources,
source_checkout,
source_skills_root,
validate_source_name,
)
from skiff.yaml_io import safe_dump from skiff.yaml_io import safe_dump
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
@@ -70,35 +89,150 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None
return names return names
def _ensure_external_fetched(name: str) -> None: def _ensure_source_fetched(name: str, source: str | None = None) -> None:
name, source = split_skill_spec(name, source)
registry = load_registry() registry = load_registry()
sources = load_sources()
if source in sources:
root = source_skills_root(source, sources[source])
if not root.is_dir():
_print(f"拉取 source: {source}")
fetch_source(source, sources[source])
return
registry_name = (
source.split(":", 1)[1]
if source and source.startswith("registry:")
else source
if source in registry
else name
if name in registry
else None
)
if registry_name and (
source in (None, "registry", registry_name)
or source == f"registry:{registry_name}"
):
_ensure_registry_fetched(registry_name, registry[registry_name])
return
if source not in (None, "registry"):
return
if name not in registry: if name not in registry:
return return
entry = registry[name] _ensure_registry_fetched(name, registry[name])
def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
path = external_skill_path(name, entry) path = external_skill_path(name, entry)
if path.exists(): if (path / "SKILL.md").is_file() or discover_external_skills(name, entry):
return return
repo = entry["repo"] repo = str(entry["repo"])
ref = entry.get("ref", "main") ref = entry.get("ref", "main")
dest = EXTERNALS_DIR / name dest = external_checkout_path(name, entry)
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise SystemExit(
f"外部仓库已存在但 skill 路径无效: {external_skill_path(name, entry)}"
)
_print(f"拉取外部 skill: {name}") _print(f"拉取外部 skill: {name}")
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)], ["git", "clone", "--branch", ref, "--", repo, str(dest)],
check=True, check=True,
) )
if not discover_external_skills(name, entry):
raise SystemExit(
f"registry 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}"
)
def _install_skill(name: str, targets: list[str], project_root: Path | None = None) -> None: def _registry_skill_names(
_ensure_external_fetched(name) name: str,
skill_path, _ = resolve_skill_source(name) entry: dict[str, object] | None = None,
for target in targets: ) -> list[str]:
link = agent_skill_dir(target, project_root=project_root) / name entry = entry or load_registry().get(name)
create_link(link, skill_path) if not entry:
raise SystemExit(f"registry 中不存在: {name}")
_ensure_registry_fetched(name, entry)
names = list(discover_external_skills(name, entry))
for skill_name in names:
validate_skill_name(skill_name)
if not names:
raise SystemExit(f"registry 条目 {name!r} 中没有可安装的 skill")
return names
def _expand_install_request(
spec: str,
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():
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 (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])
if name not in available:
raise SystemExit(
f"registry 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])
if (root / "SKILL.md").is_file():
return [(name, "registry")]
return [(skill_name, f"registry:{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, {}
def _install_skill(
name: str,
targets: list[str],
project_root: Path | None = None,
*,
source: str | None = None,
) -> str:
name, source = split_skill_spec(name, source)
_ensure_source_fetched(name, source)
skill_path, resolved_source = resolve_skill_source(name, source=source)
links = [
(target, agent_skill_dir(target, project_root=project_root) / name)
for target in targets
]
original: list[tuple[Path, str | None]] = []
try:
for _, link in links:
previous = str(link.readlink()) if link.is_symlink() else None
original.append((link, previous))
create_link(link, skill_path)
except Exception:
for link, previous in reversed(original):
if link.is_symlink():
link.unlink()
if previous is not None:
link.symlink_to(previous)
raise
for target, _ in links:
scope = "全局" if project_root is None else "项目" scope = "全局" if project_root is None else "项目"
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}") _print(f"已安装 ({scope}/{target}): {resolved_source}/{name} -> {skill_path}")
return resolved_source
def _list_installed_names(project_root: Path | None, targets: list[str]) -> list[str]: def _list_installed_names(project_root: Path | None, targets: list[str]) -> list[str]:
@@ -115,6 +249,34 @@ def _list_installed_names(project_root: Path | None, targets: list[str]) -> list
return sorted(names) return sorted(names)
def _list_fully_installed_names(
project_root: Path | None,
targets: list[str],
) -> list[str]:
installed_by_target = [
set(_list_installed_names(project_root, [target]))
for target in targets
]
if not installed_by_target:
return []
return sorted(set.intersection(*installed_by_target))
def _is_fully_installed(
name: str,
expected: Path,
project_root: Path | None,
targets: list[str],
) -> bool:
return all(
check_link(
agent_skill_dir(target, project_root=project_root) / name,
expected,
).ok
for target in targets
)
def _remove_skill( def _remove_skill(
name: str, name: str,
targets: list[str], targets: list[str],
@@ -138,18 +300,32 @@ def cmd_list(args: argparse.Namespace) -> None:
ensure_skills_home() ensure_skills_home()
owned = list_owned_skills() owned = list_owned_skills()
registry = load_registry() registry = load_registry()
custom = (
{}
if args.source in ("owned", "registry")
else list_custom_skills(args.source)
)
_print("自研 (owned):") if args.source in (None, "owned"):
for name in owned: _print("自研 (owned):")
_print(f" {name}") for name in owned:
_print(f" {name}")
_print("\n外部 (registry):") if args.source in (None, "registry"):
if not registry: _print("\n外部 (registry):")
_print(" (无)") if not registry:
else: _print(" (无)")
for name, entry in registry.items(): else:
repo = entry.get("repo", "?") for name, entry in registry.items():
_print(f" {name} ({repo})") repo = entry.get("repo", "?")
_print(f" {name} ({repo})")
for source, names in custom.items():
_print(f"\n自定义 ({source}):")
if not names:
_print(" (无,或 source 尚未 fetch)")
for name in names:
_print(f" {name}")
def cmd_bootstrap(args: argparse.Namespace) -> None: def cmd_bootstrap(args: argparse.Namespace) -> None:
@@ -161,8 +337,14 @@ def cmd_bootstrap(args: argparse.Namespace) -> None:
_print("已安装项目 skill 到所有 agent") _print("已安装项目 skill 到所有 agent")
def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]: def _installed_links(
skill_path, _ = resolve_skill_source(name) name: str,
targets: list[str],
project_root: Path | None = None,
*,
source: str | None = None,
) -> list[tuple[str, Path, Path]]:
skill_path, _ = resolve_skill_source(name, source=source)
rows: list[tuple[str, Path, Path]] = [] rows: list[tuple[str, Path, Path]] = []
for target in targets: for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name link = agent_skill_dir(target, project_root=project_root) / name
@@ -175,16 +357,36 @@ def cmd_status(args: argparse.Namespace) -> None:
targets = resolve_agent_args(flatten_agent_args(args.agents)) targets = resolve_agent_args(flatten_agent_args(args.agents))
owned = list_owned_skills() owned = list_owned_skills()
registry = load_registry() registry = load_registry()
all_names = owned + [n for n in registry if n not in owned] 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)
if not discovered:
unfetched_registry.append(package)
elif (external_skill_path(package, entry) / "SKILL.md").is_file():
entries.append(("registry", package))
else:
entries.extend(
(f"registry:{package}", skill_name)
for skill_name in discovered
)
entries.extend((source, name) for source, names in custom.items() for name in names)
_print(f"skills 仓库: {SKILLS_HOME.resolve()}") _print(f"skills 仓库: {SKILLS_HOME.resolve()}")
_print(f"agents: {', '.join(targets)}\n") _print(f"agents: {', '.join(targets)}\n")
for name in all_names: for package in unfetched_registry:
kind = "owned" if name in owned else "external" _print(f"[registry] {package}\n (未 fetch)\n")
_print(f"[{kind}] {name}")
for source, name in entries:
_print(f"[{source}] {name}")
try: try:
rows = _installed_links(name, targets) skill_path, _ = resolve_skill_source(name, source=source)
rows = [
(target, agent_skill_dir(target) / name, skill_path)
for target in targets
]
except SystemExit: except SystemExit:
_print(" (未 fetch)") _print(" (未 fetch)")
continue continue
@@ -217,12 +419,24 @@ def cmd_add(args: argparse.Namespace) -> None:
ensure_skills_home() ensure_skills_home()
if args.list_available: if args.list_available:
_print_available_skills() if args.source:
cmd_list(argparse.Namespace(source=args.source))
else:
_print_available_skills()
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装") _print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
return return
if args.all: if args.all:
names = list_owned_skills() if args.source:
if args.source == "owned":
names = list_owned_skills()
elif args.source == "registry":
names = list(load_registry())
else:
_ensure_source_fetched("", args.source)
names = list_custom_skills(args.source)[args.source]
else:
names = list_owned_skills()
targets = resolve_agent_args(["*"]) targets = resolve_agent_args(["*"])
else: else:
names = _collect_skill_names(args.skills, args.skills_flag) names = _collect_skill_names(args.skills, args.skills_flag)
@@ -232,9 +446,209 @@ def cmd_add(args: argparse.Namespace) -> None:
project_root = None if args.global_scope else _project_root(args.project) project_root = None if args.global_scope else _project_root(args.project)
for name in names: requests = [
request
for name in names
for request in _expand_install_request(name, args.source)
]
requests = list(dict.fromkeys(requests))
original_links: list[tuple[Path, str | None]] = []
for skill_name, source in requests:
validate_skill_name(skill_name)
_ensure_source_fetched(skill_name, source)
resolve_skill_source(skill_name, source=source)
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / skill_name
if link.exists() and not link.is_symlink():
raise FileExistsError(f"已存在非软链路径: {link}")
original_links.append(
(link, str(link.readlink()) if link.is_symlink() else None)
)
manifest_path = project_root / ".skills.yaml" if project_root else None
manifest_before = (
manifest_path.read_bytes()
if manifest_path and manifest_path.is_file()
else None
)
try:
for skill_name, source in requests:
resolved_source = _install_skill(
skill_name,
targets,
project_root=project_root,
source=source,
)
if manifest_path is not None:
manifest_source, extra = _manifest_source_details(resolved_source)
add_skill_to_manifest(
manifest_path,
skill_name,
source=manifest_source,
extra=extra or None,
)
except Exception:
for link, previous in reversed(original_links):
if link.is_symlink():
link.unlink()
if previous is not None:
link.symlink_to(previous)
if manifest_path is not None:
if manifest_before is None:
if manifest_path.is_file():
manifest_path.unlink()
else:
manifest_path.write_bytes(manifest_before)
raise
def cmd_select(args: argparse.Namespace) -> None:
ensure_skills_home()
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise SystemExit(
"`skiff select` 需要交互式终端;非交互环境请使用 `skiff add <name>...`"
)
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]:
validate_skill_name(name) validate_skill_name(name)
_install_skill(name, targets, project_root=project_root)
choices = [
SkillChoice(
name=name,
kind="owned",
description=skill_description(name) or "",
installed=_is_fully_installed(
name,
SKILLS_DIR / name,
project_root,
targets,
),
)
for name in owned_names
]
choice_requests: dict[str, tuple[str, str]] = {
name: (name, "owned") for name in owned_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
choices.append(
SkillChoice(
name=package,
kind="external",
description=str(entry.get("description", "")),
installed=_is_fully_installed(
package,
root,
project_root,
targets,
),
)
)
choice_requests[package] = (package, "registry")
continue
for skill_name in skill_names:
choice_name = f"{package}/{skill_name}"
description = read_skill_meta(root / skill_name).get("description", "")
choices.append(
SkillChoice(
name=choice_name,
kind=f"external:{package}",
description=description,
installed=_is_fully_installed(
skill_name,
root / skill_name,
project_root,
targets,
),
)
)
choice_requests[choice_name] = (skill_name, f"registry:{package}")
try:
selected = select_skills(choices)
except (RuntimeError, OSError) as exc:
raise SystemExit(str(exc)) from exc
if selected is None:
_print("已取消,未修改环境")
return
selected_installed_keys = {
choice.name for choice in choices if choice.installed
}
selected_outputs: dict[str, str] = {}
for key in selected:
skill_name, _ = choice_requests[key]
previous = selected_outputs.get(skill_name)
if previous and previous != key:
raise SystemExit(
f"选择冲突: {previous} 和 {key} 都会安装为 {skill_name!r}"
)
selected_outputs[skill_name] = key
names = sorted(selected - selected_installed_keys)
failures: list[tuple[str, str]] = []
manifest_path = project_root / ".skills.yaml" if project_root else None
successful = set(selected & selected_installed_keys)
for name in names:
skill_name, source = choice_requests[name]
try:
_install_skill(
skill_name,
targets,
project_root=project_root,
source=source,
)
successful.add(name)
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
failures.append((name, str(exc)))
_err(f"✗ {name}: {exc}")
if manifest_path is not 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,
)
installed_count = len(names) - len(failures)
if not names:
_print("没有需要安装的新 skill")
else:
_print(f"安装完成: {installed_count} 成功,{len(failures)} 失败")
if failures:
raise SystemExit(1)
def cmd_remove(args: argparse.Namespace) -> None: def cmd_remove(args: argparse.Namespace) -> None:
@@ -250,9 +664,32 @@ def cmd_remove(args: argparse.Namespace) -> None:
if not names: if not names:
raise SystemExit("请指定 skill 名称,或使用 skiff remove --all") raise SystemExit("请指定 skill 名称,或使用 skiff remove --all")
registry = load_registry()
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]):
raise SystemExit(
f"registry collection {source!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])
expanded.extend(
[name]
if (root / "SKILL.md").is_file()
else list(discovered)
)
else:
expanded.append(name)
total = 0 total = 0
for name in names: for name in dict.fromkeys(expanded):
total += _remove_skill(name, targets, project_root=project_root) total += _remove_skill(name, targets, project_root=project_root)
if project_root is not None:
remove_skill_from_manifest(project_root / ".skills.yaml", name)
if total == 0: if total == 0:
_print("没有移除任何 skill") _print("没有移除任何 skill")
@@ -294,7 +731,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
entry = registry[args.name] entry = registry[args.name]
repo = entry["repo"] repo = entry["repo"]
ref = entry.get("ref", "main") ref = entry.get("ref", "main")
dest = EXTERNALS_DIR / args.name dest = external_checkout_path(args.name, entry)
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True) EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
@@ -311,6 +748,94 @@ def cmd_fetch(args: argparse.Namespace) -> None:
) )
def cmd_source_add(args: argparse.Namespace) -> None:
validate_source_name(args.name)
sources = load_sources()
if args.name in sources:
raise SystemExit(f"source 已存在: {args.name}")
if bool(args.repo) == bool(args.local):
raise SystemExit("必须且只能指定 Git repo 或 --local")
entry: dict[str, str] = {"skills_path": args.skills_path}
if args.local:
local = Path(args.local).expanduser().resolve()
if not local.is_dir():
raise SystemExit(f"本地仓库不存在: {local}")
entry["local_path"] = str(local)
else:
entry.update({"repo": args.repo, "ref": args.ref})
if args.checkout:
entry["checkout"] = str(Path(args.checkout).expanduser().resolve())
# 注册前先校验路径不能逃出仓库。
source_skills_root(args.name, entry)
if not args.local and not args.no_fetch:
_print(f"克隆 source: {args.name}")
fetch_source(args.name, entry)
sources[args.name] = entry
save_sources(sources)
_print(f"已添加 source: {args.name} ({CONFIG_FILE})")
def cmd_source_list(args: argparse.Namespace) -> None:
del args
sources = load_sources()
if not sources:
_print("未配置 custom source")
return
for name, entry in sources.items():
kind = "local" if entry.get("local_path") else "git"
_print(
f"{name} [{kind}] checkout={source_checkout(name, entry)} "
f"skills={source_skills_root(name, entry)}"
)
def cmd_source_show(args: argparse.Namespace) -> None:
sources = load_sources()
if args.name not in sources:
raise SystemExit(f"未配置 source: {args.name}")
entry = sources[args.name]
_print(f"name: {args.name}")
for key, value in entry.items():
_print(f"{key}: {value}")
_print(f"checkout: {source_checkout(args.name, entry)}")
_print(f"skills_root: {source_skills_root(args.name, entry)}")
def cmd_source_fetch(args: argparse.Namespace) -> None:
sources = load_sources()
names = list(sources) if args.all else [args.name]
if not names or names == [None]:
raise SystemExit("请指定 source 名称,或使用 --all")
for name in names:
if name not in sources:
raise SystemExit(f"未配置 source: {name}")
_print(f"更新 source: {name}")
fetch_source(name, sources[name])
def cmd_source_remove(args: argparse.Namespace) -> None:
sources = load_sources()
if args.name not in sources:
raise SystemExit(f"未配置 source: {args.name}")
entry = sources[args.name]
if args.delete_checkout and entry.get("local_path"):
raise SystemExit("不会删除 --local 指定的仓库")
checkout = source_checkout(args.name, entry)
if args.delete_checkout and (
checkout in (Path("/"), Path.home().resolve()) or not (checkout / ".git").is_dir()
):
raise SystemExit(f"拒绝删除不安全或非 Git checkout: {checkout}")
sources.pop(args.name)
save_sources(sources)
_print(f"已移除 source 配置: {args.name}")
if args.delete_checkout:
import shutil
if checkout.is_dir():
shutil.rmtree(checkout)
_print(f"已删除 checkout(不可恢复): {checkout}")
def cmd_enable(args: argparse.Namespace) -> None: def cmd_enable(args: argparse.Namespace) -> None:
_warn_deprecated("skiff enable", "skiff add <name>") _warn_deprecated("skiff enable", "skiff add <name>")
ensure_skills_home() ensure_skills_home()
@@ -318,13 +843,10 @@ def cmd_enable(args: argparse.Namespace) -> None:
root = _project_root(args.project) root = _project_root(args.project)
manifest_path = root / ".skills.yaml" manifest_path = root / ".skills.yaml"
registry = load_registry() name, source = split_skill_spec(args.name)
if args.name in registry: _ensure_source_fetched(name, source)
extra = {"source": "registry", "ref": registry[args.name].get("ref", "main")} _, resolved_source = resolve_skill_source(name, source=source)
add_skill_to_manifest(manifest_path, args.name, source="registry", extra=extra) add_skill_to_manifest(manifest_path, name, source=resolved_source)
else:
owned_skill_path(args.name)
add_skill_to_manifest(manifest_path, args.name, source="owned")
targets = resolve_agent_args(flatten_agent_args(args.agents)) targets = resolve_agent_args(flatten_agent_args(args.agents))
_, data = load_manifest(manifest_path) _, data = load_manifest(manifest_path)
@@ -332,8 +854,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
if manifest_targets: if manifest_targets:
targets = [t for t in targets if t in manifest_targets] targets = [t for t in targets if t in manifest_targets]
_install_skill(args.name, targets, project_root=root) _install_skill(name, targets, project_root=root, source=resolved_source)
_print(f"已启用项目 skill: {args.name} @ {root}") _print(f"已启用项目 skill: {resolved_source}/{name} @ {root}")
def cmd_disable(args: argparse.Namespace) -> None: def cmd_disable(args: argparse.Namespace) -> None:
@@ -363,8 +885,15 @@ def cmd_sync(args: argparse.Namespace) -> None:
for entry in iter_manifest_skills(data): for entry in iter_manifest_skills(data):
name = entry["name"] 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_path, _ = resolve_manifest_skill(entry)
for target in targets: skill_targets = targets
if entry.get("targets"):
skill_targets = [target for target in targets if target in entry["targets"]]
for target in skill_targets:
link = agent_skill_dir(target, project_root=root) / name link = agent_skill_dir(target, project_root=root) / name
create_link(link, skill_path) create_link(link, skill_path)
_print(f"已同步: {link} -> {skill_path}") _print(f"已同步: {link} -> {skill_path}")
@@ -471,7 +1000,7 @@ def cmd_doctor(args: argparse.Namespace) -> None:
issues += 1 issues += 1
for name in list_owned_skills(): for name in list_owned_skills():
for target, link, expected in _installed_links(name, targets): for target, link, expected in _installed_links(name, targets, source="owned"):
status = check_link(link, expected) status = check_link(link, expected)
if status.ok: if status.ok:
continue continue
@@ -491,6 +1020,16 @@ def cmd_doctor(args: argparse.Namespace) -> None:
_err(f"✗ 外部 skill 未 fetch: {name}") _err(f"✗ 外部 skill 未 fetch: {name}")
issues += 1 issues += 1
for source, entry in load_sources().items():
checkout = source_checkout(source, entry)
root = source_skills_root(source, entry)
if not checkout.is_dir():
_err(f"✗ source checkout 不存在: {source} -> {checkout}")
issues += 1
elif not root.is_dir():
_err(f"✗ source skills_path 不存在: {source} -> {root}")
issues += 1
if issues == 0: if issues == 0:
_print("\n全部正常") _print("\n全部正常")
else: else:
@@ -536,7 +1075,8 @@ def build_parser() -> argparse.ArgumentParser:
) )
p_bootstrap.set_defaults(func=cmd_bootstrap) p_bootstrap.set_defaults(func=cmd_bootstrap)
p_list = sub.add_parser("list", help="列出 ~/.skills 中的 skill 目录") p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
p_list.add_argument("--source", help="只列出指定来源(owned、registry 或 custom source)")
p_list.set_defaults(func=cmd_list) p_list.set_defaults(func=cmd_list)
p_status = sub.add_parser("status", help="安装状态总览") p_status = sub.add_parser("status", help="安装状态总览")
@@ -552,9 +1092,17 @@ def build_parser() -> argparse.ArgumentParser:
p_add.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="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("--list", dest="list_available", action="store_true", help="列出可用自研 skill,不安装")
p_add.add_argument("--all", action="store_true", help="安装全部自研 skill 到全部 agent") 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) _add_common_flags(p_add)
p_add.set_defaults(func=cmd_add) p_add.set_defaults(func=cmd_add)
p_select = sub.add_parser(
"select",
help="交互式选择并批量安装 skill",
)
_add_common_flags(p_select)
p_select.set_defaults(func=cmd_select)
p_remove = sub.add_parser( p_remove = sub.add_parser(
"remove", "remove",
aliases=["rm", "r"], aliases=["rm", "r"],
@@ -589,6 +1137,40 @@ def build_parser() -> argparse.ArgumentParser:
p_fetch.add_argument("name", help="registry 名称") p_fetch.add_argument("name", help="registry 名称")
p_fetch.set_defaults(func=cmd_fetch) p_fetch.set_defaults(func=cmd_fetch)
p_source = sub.add_parser("source", help="管理包含多个 skills 的自定义仓库")
source_sub = p_source.add_subparsers(dest="source_command", required=True)
p_source_add = source_sub.add_parser("add", help="注册 Git 或本地 skill source")
p_source_add.add_argument("name", help="source 名称")
p_source_add.add_argument("repo", nargs="?", help="Git 仓库 URL")
p_source_add.add_argument("--local", help="已有本地仓库路径")
p_source_add.add_argument("--ref", default="main", help="Git 分支或 tag(默认 main)")
p_source_add.add_argument("--checkout", help="Git checkout 路径")
p_source_add.add_argument("--skills-path", default="skills", help="仓库内 skills 父目录")
p_source_add.add_argument("--no-fetch", action="store_true", help="仅写配置,不立即 clone")
p_source_add.set_defaults(func=cmd_source_add)
p_source_list = source_sub.add_parser("list", help="列出已配置 source")
p_source_list.set_defaults(func=cmd_source_list)
p_source_show = source_sub.add_parser("show", help="显示 source 详情")
p_source_show.add_argument("name")
p_source_show.set_defaults(func=cmd_source_show)
p_source_fetch = source_sub.add_parser("fetch", help="克隆或更新 source")
p_source_fetch.add_argument("name", nargs="?")
p_source_fetch.add_argument("--all", action="store_true")
p_source_fetch.set_defaults(func=cmd_source_fetch)
p_source_remove = source_sub.add_parser("remove", help="移除 source 配置")
p_source_remove.add_argument("name")
p_source_remove.add_argument(
"--delete-checkout",
action="store_true",
help="同时永久删除 skiff 管理的 checkout",
)
p_source_remove.set_defaults(func=cmd_source_remove)
p_enable = sub.add_parser("enable", help=argparse.SUPPRESS) p_enable = sub.add_parser("enable", help=argparse.SUPPRESS)
p_enable.add_argument("name") p_enable.add_argument("name")
p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append") p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
+2
View File
@@ -11,6 +11,8 @@ TEMPLATE_DIR = SKILLS_DIR / "_template"
DRAFTS_DIR = SKILLS_HOME / ".drafts" DRAFTS_DIR = SKILLS_HOME / ".drafts"
REGISTRY_FILE = SKILLS_HOME / "registry.yaml" REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals" EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
CONFIG_FILE = HOME / ".config" / "skiff" / "config.yaml"
SOURCES_DIR = HOME / ".local" / "share" / "skiff" / "sources"
PROJECT_MANIFEST = ".skills.yaml" PROJECT_MANIFEST = ".skills.yaml"
AGENT_GLOBAL: dict[str, Path] = { AGENT_GLOBAL: dict[str, Path] = {
+9 -4
View File
@@ -65,13 +65,16 @@ def add_skill_to_manifest(
file_path = path file_path = path
entries = [normalize_skill_entry(e) for e in data.get("skills", [])] entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
if any(e["name"] == name for e in entries):
return
item: dict[str, Any] = {"name": name, "source": source} item: dict[str, Any] = {"name": name, "source": source}
if extra: if extra:
item.update(extra) item.update(extra)
entries.append(item) existing_index = next((i for i, entry in enumerate(entries) if entry["name"] == name), None)
if existing_index is None:
entries.append(item)
elif entries[existing_index] == item:
return
else:
entries[existing_index] = item
data["skills"] = [_entry_to_yaml(e) for e in entries] data["skills"] = [_entry_to_yaml(e) for e in entries]
save_manifest(file_path, data) save_manifest(file_path, data)
@@ -98,4 +101,6 @@ def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]: def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
name = entry["name"] name = entry["name"]
source = entry.get("source", "owned") source = entry.get("source", "owned")
if source == "registry" and entry.get("registry"):
source = f"registry:{entry['registry']}"
return resolve_skill_source(name, source=source) return resolve_skill_source(name, source=source)
+76 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -25,9 +26,82 @@ def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) ->
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8") path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path: def external_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 EXTERNALS_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
def external_checkout_path(name: str, entry: dict[str, Any]) -> Path:
"""Prefer an existing pre-shared-cache checkout for compatibility."""
from skiff.paths import EXTERNALS_DIR
legacy = EXTERNALS_DIR / name
return legacy if legacy.is_dir() else external_repo_path(entry)
def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
entry = entry or load_registry().get(name, {}) entry = entry or load_registry().get(name, {})
subpath = entry.get("path", ".") or "." subpath = entry.get("path", ".") or "."
return (EXTERNALS_DIR / name / subpath).resolve() checkout = external_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}"
) from exc
return skill_path
def discover_external_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)
if (root / "SKILL.md").is_file():
return {name: root}
if not root.is_dir():
return {}
resolved_root = root.resolve()
skills: dict[str, Path] = {}
for item in sorted(root.iterdir()):
skill_md = item / "SKILL.md"
if (
item.name.startswith("_")
or item.is_symlink()
or not item.is_dir()
or skill_md.is_symlink()
or not skill_md.is_file()
):
continue
resolved_item = item.resolve()
resolved_skill_md = skill_md.resolve()
try:
resolved_item.relative_to(resolved_root)
resolved_skill_md.relative_to(resolved_item)
except ValueError:
continue
skills[item.name] = item
return skills
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]
+113
View File
@@ -0,0 +1,113 @@
"""Terminal multi-select UI for skills."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True)
class SkillChoice:
name: str
kind: str
description: str = ""
installed: bool = False
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
needle = query.casefold().strip()
if not needle:
return list(choices)
return [
choice
for choice in choices
if needle in choice.name.casefold()
or needle in choice.kind.casefold()
or needle in choice.description.casefold()
]
def select_skills(
choices: list[SkillChoice],
*,
wrapper: Callable[..., set[str] | None] | None = None,
) -> set[str] | None:
"""Open the selector. Return names, or None when cancelled."""
if not choices:
return set()
try:
import curses
except ImportError as exc:
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
selected = {choice.name for choice in choices if choice.installed}
def draw(stdscr: object) -> set[str] | None:
curses.curs_set(0)
stdscr.keypad(True)
current = 0
query = ""
while True:
visible = filter_choices(choices, query)
current = min(current, max(0, len(visible) - 1))
stdscr.erase()
height, width = stdscr.getmaxyx()
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消"
stdscr.addnstr(0, 0, header, max(0, width - 1))
if query:
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
rows = max(1, height - 4)
start = max(0, current - rows + 1)
for row, choice in enumerate(visible[start : start + rows], start=2):
index = start + row - 2
mark = "x" if choice.name in selected else " "
suffix = f" · {choice.description}" if choice.description else ""
line = f"[{mark}] {choice.name} {choice.kind}{suffix}"
attr = curses.A_REVERSE if index == current else curses.A_NORMAL
stdscr.addnstr(row, 0, line, max(0, width - 1), attr)
footer = f"已选择 {len(selected)} 项"
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1))
stdscr.refresh()
key = stdscr.get_wch()
if key in ("q", "Q", "\x1b"):
return None
if key in ("\n", "\r") or key == curses.KEY_ENTER:
return set(selected)
if key == curses.KEY_UP and visible:
current = (current - 1) % len(visible)
elif key == curses.KEY_DOWN and visible:
current = (current + 1) % len(visible)
elif key == " " and visible:
name = visible[current].name
selected.symmetric_difference_update({name})
elif key == "/":
curses.curs_set(1)
query = _read_query(stdscr, curses, width)
curses.curs_set(0)
current = 0
runner = wrapper or curses.wrapper
return runner(draw)
def _read_query(stdscr: object, curses: object, width: int) -> str:
query = ""
while True:
stdscr.move(1, 0)
stdscr.clrtoeol()
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
stdscr.refresh()
key = stdscr.get_wch()
if key in ("\n", "\r") or key == curses.KEY_ENTER:
return query
if key == "\x1b":
return ""
if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE:
query = query[:-1]
elif isinstance(key, str) and key.isprintable():
query += key
+90 -11
View File
@@ -6,7 +6,12 @@ import re
from pathlib import Path from pathlib import Path
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
from skiff.registry import external_skill_path, load_registry from skiff.registry import (
external_collection_skill_path,
external_skill_path,
load_registry,
)
from skiff.sources import list_source_skills, load_sources, source_skills_root
def list_owned_skills() -> list[str]: def list_owned_skills() -> list[str]:
@@ -31,25 +36,99 @@ def owned_skill_path(name: str) -> Path:
return path return path
def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]:
if "/" not in spec:
return spec, 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)")
if source and source != qualified_source:
raise SystemExit(
f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致"
)
return name, qualified_source
def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
sources = load_sources()
if source:
if source not in sources:
raise SystemExit(f"未配置 source: {source}")
return {source: list_source_skills(source, sources[source])}
return {name: list_source_skills(name, entry) for name, entry in sources.items()}
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]: def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
"""返回 (skill_path, kind),kind 为 owned 或 external。""" """返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
ensure_skills_home() ensure_skills_home()
name, source = split_skill_spec(name, source)
owned = SKILLS_DIR / name owned = SKILLS_DIR / name
if source in (None, "owned") and (owned / "SKILL.md").is_file(): if source == "owned":
if not (owned / "SKILL.md").is_file():
raise SystemExit(f"owned source 中找不到 skill: {name}")
return owned, "owned" return owned, "owned"
registry = load_registry() registry = load_registry()
if source in (None, "registry") and name in registry: sources = load_sources()
path = external_skill_path(name, registry[name]) registry_collection = None
if not path.exists(): if source and source.startswith("registry:"):
raise SystemExit( registry_collection = source.split(":", 1)[1]
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}" elif (
) source
return path, "external" 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 == "registry":
if name not in registry:
raise SystemExit(f"registry 中找不到 skill: {name}")
path = external_skill_path(name, registry[name])
if not (path / "SKILL.md").is_file():
raise SystemExit(
f"外部 skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
f"请运行: skiff fetch {name}"
)
return path, "registry"
if source:
if source not in sources:
raise SystemExit(
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():
raise SystemExit(f"source {source!r} 中找不到 skill: {name}")
return path, source
candidates: list[tuple[Path, str]] = []
if (owned / "SKILL.md").is_file(): if (owned / "SKILL.md").is_file():
return owned, "owned" candidates.append((owned, "owned"))
if name in registry:
candidates.append((external_skill_path(name, registry[name]), "registry"))
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))
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}")
return path, resolved_source
raise SystemExit(f"找不到 skill: {name}") raise SystemExit(f"找不到 skill: {name}")
+106
View File
@@ -0,0 +1,106 @@
"""命名 custom skill source 的配置、发现与 Git 管理。"""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any
from skiff import yaml_io
from skiff.paths import CONFIG_FILE, SOURCES_DIR
RESERVED_SOURCES = {"owned", "registry"}
def validate_source_name(name: str) -> None:
from skiff.skills import validate_skill_name
validate_skill_name(name)
if name in RESERVED_SOURCES:
raise SystemExit(f"source 名称为保留字: {name}")
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or CONFIG_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"skiff 配置格式错误: {path}")
raw = data.get("sources", {})
if raw is None:
return {}
if not isinstance(raw, dict):
raise SystemExit(f"skiff 配置 sources 格式错误: {path}")
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
local_path = entry.get("local_path")
if local_path:
return Path(str(local_path)).expanduser().resolve()
checkout = entry.get("checkout")
if checkout:
return Path(str(checkout)).expanduser().resolve()
return (SOURCES_DIR / name).resolve()
def source_skills_root(name: str, entry: dict[str, Any]) -> Path:
checkout = source_checkout(name, entry)
raw_subpath = str(entry.get("skills_path", "skills") or "skills")
subpath = Path(raw_subpath)
if subpath.is_absolute():
raise SystemExit(f"source {name!r} 的 skills_path 必须是仓库内相对路径")
root = (checkout / subpath).resolve()
try:
root.relative_to(checkout)
except ValueError:
raise SystemExit(f"source {name!r} 的 skills_path 不能超出仓库目录") from None
return root
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
root = source_skills_root(name, entry)
if not root.is_dir():
return []
return [
item.name
for item in sorted(root.iterdir())
if item.is_dir() and not item.name.startswith("_") and (item / "SKILL.md").is_file()
]
def fetch_source(name: str, entry: dict[str, Any]) -> Path:
if entry.get("local_path"):
checkout = source_checkout(name, entry)
if not checkout.is_dir():
raise SystemExit(f"source {name!r} 的本地仓库不存在: {checkout}")
return checkout
repo = entry.get("repo")
if not repo:
raise SystemExit(f"source {name!r} 缺少 repo 或 local_path")
checkout = source_checkout(name, entry)
ref = str(entry.get("ref", "main") or "main")
if checkout.exists():
if not (checkout / ".git").exists():
raise SystemExit(f"source checkout 已存在但不是 Git 仓库: {checkout}")
subprocess.run(["git", "-C", str(checkout), "fetch", "--all", "--tags"], check=True)
subprocess.run(["git", "-C", str(checkout), "checkout", ref], check=True)
subprocess.run(["git", "-C", str(checkout), "pull", "--ff-only"], check=True)
else:
checkout.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "clone", "--branch", ref, "--", str(repo), str(checkout)],
check=True,
)
return checkout
+3 -2
View File
@@ -236,11 +236,12 @@ def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
lines: list[str] = [] lines: list[str] = []
first = True first = True
for key, value in item.items(): for key, value in item.items():
prefix = f"{pad}- " if first else f"{pad} " is_first = first
prefix = f"{pad}- " if is_first else f"{pad} "
first = False first = False
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
lines.append(f"{prefix}{key}:") lines.append(f"{prefix}{key}:")
nested = _dump(value, indent + 4 if first else indent + 2) nested = _dump(value, indent + 2 if is_first else indent + 4)
lines.append(nested.rstrip()) lines.append(nested.rstrip())
else: else:
lines.append(f"{prefix}{key}: {_scalar(value)}") lines.append(f"{prefix}{key}: {_scalar(value)}")
+64
View File
@@ -0,0 +1,64 @@
---
name: publish-docker-image
description: >-
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
$publish-docker-image 或明确说“使用 publish-docker-image skill”时使用;
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
---
# 发布 Docker 镜像
安全、可复现地构建当前提交对应的 Docker 镜像,并按用户指定的目标上传。
## 执行流程
1. 读取项目的 `AGENTS.md`、Dockerfile、构建脚本和相关发布文档。
2. 收集目标 registry、repository、tag、platform、构建上下文和 Dockerfile。优先使用用户已明确提供的值;缺少会改变发布结果的值时,停止并询问。
3. 检查 Git 工作区与当前提交。若存在未提交修改,明确说明镜像将包含哪些修改。
4. 按 [registry.md](references/registry.md) 检查仓库规则和认证状态。
5. 在执行外部写操作前,向用户展示完整镜像引用、platform、Dockerfile、构建上下文和源 commit。只有用户已明确要求上传到该目标时才继续。
6. 使用 [publish.sh](scripts/publish.sh) 构建并上传。不要自行拼接包含凭据的命令。
7. 检查命令退出状态,并尽可能获取远端 digest。
8. 汇报完整镜像引用、digest、platform、源 commit,以及是否包含未提交修改。
## 命令
默认构建并上传:
```bash
scripts/publish.sh \
--registry REGISTRY \
--repository NAMESPACE/IMAGE \
--tag TAG \
--platform PLATFORM
```
先验证而不构建或上传:
```bash
scripts/publish.sh \
--registry REGISTRY \
--repository NAMESPACE/IMAGE \
--tag TAG \
--platform PLATFORM \
--dry-run
```
仅当用户明确要求本地构建时使用 `--load`。多平台镜像不能使用 `--load`。
## 安全边界
- 不把密码、访问令牌或 Docker 配置写入 skill、项目文件、命令参数或输出。
- 不主动执行 `docker login`;认证缺失时让用户通过交互式登录或其凭据管理器完成。
- 不覆盖已存在的 release tag,除非用户明确授权。无法可靠检查远端 tag 时说明这一限制。
- 不把 `latest` 作为隐含默认 tag。
- 不上传用户未指定的附加 tag。
- 不擅自修改 Dockerfile、发布配置、仓库权限或镜像保留策略。
- 若仓库、tag、platform 或目标环境存在歧义,在上传前询问用户。
## 验证
- 确认 `docker buildx build` 成功且启用了 `--push`。
- 优先用 `docker buildx imagetools inspect FULL_IMAGE_REF` 验证远端引用及平台。
- 记录远端 digest;若仓库不允许检查,明确报告只验证了 push 命令成功。
- 将发布所用的 Git commit 与工作区状态一并报告。
@@ -0,0 +1,39 @@
# 镜像仓库规则
执行发布前,从用户输入和当前项目文档中确定以下信息:
| 字段 | 要求 |
| --- | --- |
| Registry | 必须显式确定,例如 `registry.example.com` |
| Repository | 必须包含项目约定的 namespace,例如 `team/service` |
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
| Context | 默认当前项目根目录 |
## 信息来源优先级
1. 用户本次请求中明确给出的值。
2. 当前项目的 `AGENTS.md` 和发布文档。
3. `Makefile`、CI 配置、Compose 文件或现有构建脚本中一致且无歧义的配置。
4. 询问用户。
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
## 认证
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
不要读取、打印或复制以下内容:
- registry 密码或访问令牌
- `~/.docker/config.json` 中的认证字段
- CI secret 的值
- 包含凭据的环境变量值
## Tag 策略
- release tag(如 `v1.2.3`)默认视为不可变。
- Git SHA tag 应对应当前源 commit。
- `latest`、`stable` 等浮动 tag 只有在用户明确要求时才发布。
- 用户未给 tag 且项目没有唯一明确规则时,必须询问,不要自行选择。
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
sed -n '2,22p' "$0"
}
# Build and publish a Docker image with buildx.
#
# Usage:
# publish.sh --registry HOST --repository PATH --tag TAG --platform PLATFORMS [options]
#
# Options:
# --registry HOST Registry host, without a URL scheme
# --repository PATH Repository path, such as team/service
# --tag TAG Image tag
# --platform LIST Comma-separated platforms
# --file PATH Dockerfile path (default: Dockerfile)
# --context PATH Build context (default: .)
# --builder NAME Existing buildx builder
# --load Load a single-platform image instead of pushing
# --dry-run Print the resolved build without executing it
# --help Show this help
registry=
repository=
tag=
platform=
dockerfile=Dockerfile
build_context=.
builder=
mode=push
dry_run=false
while (($#)); do
case "$1" in
--registry) registry=${2-}; shift 2 ;;
--repository) repository=${2-}; shift 2 ;;
--tag) tag=${2-}; shift 2 ;;
--platform) platform=${2-}; shift 2 ;;
--file) dockerfile=${2-}; shift 2 ;;
--context) build_context=${2-}; shift 2 ;;
--builder) builder=${2-}; shift 2 ;;
--load) mode=load; shift ;;
--dry-run) dry_run=true; shift ;;
--help|-h) usage; exit 0 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
for required_name in registry repository tag platform; do
if [[ -z ${!required_name} ]]; then
printf 'Missing required option: --%s\n' "$required_name" >&2
exit 2
fi
done
if [[ $registry == *://* || $registry == */* ]]; then
printf '%s\n' 'Registry must be a host without a URL scheme or path.' >&2
exit 2
fi
if [[ $repository == /* || $repository == */ || $repository != */* ]]; then
printf '%s\n' 'Repository must look like namespace/image.' >&2
exit 2
fi
if [[ $tag == *:* || $tag == */* ]]; then
printf '%s\n' 'Tag must not contain ":" or "/".' >&2
exit 2
fi
if [[ $mode == load && $platform == *,* ]]; then
printf '%s\n' '--load supports only one platform.' >&2
exit 2
fi
if [[ ! -f $dockerfile ]]; then
printf 'Dockerfile not found: %s\n' "$dockerfile" >&2
exit 2
fi
if [[ ! -d $build_context ]]; then
printf 'Build context not found: %s\n' "$build_context" >&2
exit 2
fi
if [[ $dry_run == false ]] && ! command -v docker >/dev/null 2>&1; then
printf '%s\n' 'docker is not installed or not available in PATH.' >&2
exit 127
fi
image_ref="${registry}/${repository}:${tag}"
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
if [[ -n $builder ]]; then
build_cmd+=(--builder "$builder")
fi
if [[ $mode == push ]]; then
build_cmd+=(--push)
else
build_cmd+=(--load)
fi
build_cmd+=("$build_context")
printf 'Image: %s\n' "$image_ref"
printf 'Platform: %s\n' "$platform"
printf 'Dockerfile: %s\n' "$dockerfile"
printf 'Context: %s\n' "$build_context"
printf 'Mode: %s\n' "$mode"
if [[ $dry_run == true ]]; then
printf 'Command:'
printf ' %q' "${build_cmd[@]}"
printf '\n'
exit 0
fi
"${build_cmd[@]}"
if [[ $mode == push ]]; then
docker buildx imagetools inspect "$image_ref"
fi
+10
View File
@@ -106,6 +106,16 @@ skiff add discussion-notes -a cursor -g -y
skiff add discussion-notes -a cursor -a codex -g -y skiff add discussion-notes -a cursor -a codex -g -y
``` ```
registry 条目既可以指向单个 skill,也可以指向包含多个 skill 目录的
collection。安装 collection 全部内容或其中一个:
```bash
skiff add waza -a codex -g -y
skiff add waza/think -a codex -g -y
```
`skiff select` 会把 collection 展开为 `waza/think`、`waza/ui` 等候选项。
卸载: 卸载:
```bash ```bash
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def write_skill(root: Path, name: str) -> Path:
skill = root / name
skill.mkdir(parents=True)
skill.joinpath("SKILL.md").write_text(
f"---\nname: {name}\ndescription: >-\n"
f" 测试 {name} skill。\n---\n\n# {name}\n",
encoding="utf-8",
)
return skill
class CustomSourceTests(unittest.TestCase):
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")
def tearDown(self) -> None:
self.temp_dir.cleanup()
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["HOME"] = str(self.home)
env["PYTHONPATH"] = str(REPO_ROOT)
return subprocess.run(
[sys.executable, "-m", "skiff", *args],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
def test_local_source_lists_and_installs_qualified_skill(self) -> None:
company = self.home / "company"
expected = write_skill(company / "internal" / "skills", "code-review")
added = self.run_skiff(
"source",
"add",
"company",
"--local",
str(company),
"--skills-path",
"internal/skills",
)
listed = self.run_skiff("list", "--source", "company")
installed = self.run_skiff("add", "company/code-review", "-g", "-a", "codex")
self.assertEqual(added.returncode, 0, added.stderr)
self.assertEqual(listed.returncode, 0, listed.stderr)
self.assertIn("code-review", listed.stdout)
self.assertEqual(installed.returncode, 0, installed.stderr)
link = self.home / ".codex" / "skills" / "code-review"
self.assertTrue(link.is_symlink())
self.assertEqual(link.resolve(), expected.resolve())
config = self.home / ".config" / "skiff" / "config.yaml"
self.assertIn("company:", config.read_text(encoding="utf-8"))
def test_project_add_persists_resolved_source_in_manifest(self) -> None:
company = self.home / "company"
expected = write_skill(company / "skills", "code-review")
project = self.home / "project"
project.mkdir()
added = self.run_skiff("source", "add", "company", "--local", str(company))
self.assertEqual(added.returncode, 0, added.stderr)
result = self.run_skiff(
"add",
"company/code-review",
"--project",
str(project),
"-a",
"codex",
)
self.assertEqual(result.returncode, 0, result.stderr)
manifest = project.joinpath(".skills.yaml").read_text(encoding="utf-8")
self.assertIn('name: "code-review"', manifest)
self.assertIn("source: company", manifest)
self.assertEqual(
(project / ".agents" / "skills" / "code-review").resolve(),
expected.resolve(),
)
def test_project_add_updates_manifest_when_source_changes(self) -> None:
write_skill(self.skills_home / "skills", "code-review")
company = self.home / "company"
write_skill(company / "skills", "code-review")
project = self.home / "project"
project.mkdir()
project.joinpath(".skills.yaml").write_text(
"skills:\n - code-review\n",
encoding="utf-8",
)
added = self.run_skiff("source", "add", "company", "--local", str(company))
self.assertEqual(added.returncode, 0, added.stderr)
result = self.run_skiff(
"add",
"company/code-review",
"--project",
str(project),
"-a",
"codex",
)
self.assertEqual(result.returncode, 0, result.stderr)
manifest = project.joinpath(".skills.yaml").read_text(encoding="utf-8")
self.assertIn("source: company", manifest)
def test_unqualified_duplicate_requires_explicit_source(self) -> None:
write_skill(self.skills_home / "skills", "code-review")
company = self.home / "company"
write_skill(company / "skills", "code-review")
added = self.run_skiff("source", "add", "company", "--local", str(company))
self.assertEqual(added.returncode, 0, added.stderr)
result = self.run_skiff("add", "code-review", "-g", "-a", "codex")
self.assertNotEqual(result.returncode, 0)
self.assertIn("owned/code-review", result.stderr)
self.assertIn("company/code-review", result.stderr)
def test_custom_source_namespace_is_not_shadowed_by_registry_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(
"company:\n"
f" repo: {registry_repo}\n"
" ref: main\n"
" path: skills\n",
encoding="utf-8",
)
added = self.run_skiff("source", "add", "company", "--local", str(company))
result = self.run_skiff(
"add",
"company/code-review",
"-g",
"-a",
"codex",
)
self.assertEqual(added.returncode, 0, added.stderr)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
(self.home / ".codex" / "skills" / "code-review").resolve(),
expected.resolve(),
)
def test_sync_uses_manifest_source(self) -> None:
company = self.home / "company"
expected = write_skill(company / "skills", "code-review")
project = self.home / "project"
project.mkdir()
project.joinpath(".skills.yaml").write_text(
"skills:\n - name: code-review\n source: company\n"
"targets:\n - codex\n",
encoding="utf-8",
)
added = self.run_skiff("source", "add", "company", "--local", str(company))
self.assertEqual(added.returncode, 0, added.stderr)
result = self.run_skiff("sync", "--project", str(project), "-a", "codex")
self.assertEqual(result.returncode, 0, result.stderr)
link = project / ".agents" / "skills" / "code-review"
self.assertEqual(link.resolve(), expected.resolve())
def test_sync_reports_missing_machine_source(self) -> None:
project = self.home / "project"
project.mkdir()
project.joinpath(".skills.yaml").write_text(
"skills:\n - name: code-review\n source: company\n",
encoding="utf-8",
)
result = self.run_skiff("sync", "--project", str(project), "-a", "codex")
self.assertNotEqual(result.returncode, 0)
self.assertIn("本机尚未配置", result.stderr)
self.assertIn("skiff source add company", result.stderr)
def test_git_source_is_cloned_to_default_checkout(self) -> None:
upstream = self.home / "upstream"
write_skill(upstream / "skills", "release-check")
subprocess.run(["git", "init", "-b", "main", str(upstream)], check=True, capture_output=True)
subprocess.run(["git", "-C", str(upstream), "add", "."], check=True)
subprocess.run(
[
"git",
"-C",
str(upstream),
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
check=True,
capture_output=True,
)
result = self.run_skiff("source", "add", "company", str(upstream))
self.assertEqual(result.returncode, 0, result.stderr)
checkout = self.home / ".local" / "share" / "skiff" / "sources" / "company"
self.assertTrue((checkout / ".git").is_dir())
self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file())
if __name__ == "__main__":
unittest.main()
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def write_skill(root: Path, name: str) -> Path:
skill = root / name
skill.mkdir(parents=True)
skill.joinpath("SKILL.md").write_text(
f"---\nname: {name}\ndescription: >-\n"
f" Test {name} skill.\n---\n\n# {name}\n",
encoding="utf-8",
)
return skill
class RegistryCollectionTests(unittest.TestCase):
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.upstream = self.home / "upstream"
self.first = write_skill(self.upstream / "skills", "first-skill")
self.second = write_skill(self.upstream / "skills", "second-skill")
subprocess.run(
["git", "init", "-b", "main", str(self.upstream)],
check=True,
capture_output=True,
)
subprocess.run(["git", "-C", str(self.upstream), "add", "."], check=True)
subprocess.run(
[
"git",
"-C",
str(self.upstream),
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
check=True,
capture_output=True,
)
self.skills_home.joinpath("registry.yaml").write_text(
"test-pack:\n"
f" repo: {self.upstream}\n"
" ref: main\n"
" path: skills\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["HOME"] = str(self.home)
env["PYTHONPATH"] = str(REPO_ROOT)
return subprocess.run(
[sys.executable, "-m", "skiff", *args],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
def test_add_collection_installs_every_discovered_skill(self) -> None:
result = self.run_skiff("add", "test-pack", "-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 / "first-skill" / "SKILL.md").is_file())
self.assertTrue((skill_dir / "second-skill").is_symlink())
self.assertTrue((skill_dir / "second-skill" / "SKILL.md").is_file())
self.assertFalse((skill_dir / "test-pack").exists())
def test_add_qualified_collection_skill_installs_only_that_skill(self) -> None:
result = self.run_skiff("add", "test-pack/second-skill", "-g", "-a", "codex")
self.assertEqual(result.returncode, 0, result.stderr)
skill_dir = self.home / ".codex" / "skills"
self.assertFalse((skill_dir / "first-skill").exists())
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:
result = self.run_skiff(
"add",
"test-pack",
"--source",
"registry",
"-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_project_add_records_collection_for_sync(self) -> None:
project = self.home / "project"
project.mkdir()
added = self.run_skiff(
"add",
"test-pack/first-skill",
"--project",
str(project),
"-a",
"codex",
)
link = project / ".agents" / "skills" / "first-skill"
link.unlink()
shutil.rmtree(self.home / ".local" / "share" / "skills" / "externals")
synced = self.run_skiff(
"sync",
"--project",
str(project),
"-a",
"codex",
)
self.assertEqual(added.returncode, 0, added.stderr)
self.assertIn('registry: "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())
def test_collection_preflight_prevents_partial_install(self) -> None:
blocked = self.home / ".codex" / "skills" / "second-skill"
blocked.mkdir(parents=True)
result = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.home / ".codex" / "skills" / "first-skill").exists())
self.assertTrue(blocked.is_dir())
def test_remove_collection_removes_all_child_links(self) -> None:
installed = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
removed = self.run_skiff("remove", "test-pack", "-g", "-a", "codex")
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(removed.returncode, 0, removed.stderr)
skill_dir = self.home / ".codex" / "skills"
self.assertFalse((skill_dir / "first-skill").exists())
self.assertFalse((skill_dir / "second-skill").exists())
def test_status_expands_collection_children(self) -> None:
installed = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
status = self.run_skiff("status", "-a", "codex")
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)
if __name__ == "__main__":
unittest.main()
+300
View File
@@ -0,0 +1,300 @@
from __future__ import annotations
import argparse
import curses
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
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.selector import SkillChoice, filter_choices, select_skills
REPO_ROOT = Path(__file__).resolve().parents[1]
class SelectorTests(unittest.TestCase):
def test_manifest_entry_targets_round_trip(self) -> None:
data = {
"skills": [
{
"name": "external-one",
"source": "registry",
"targets": ["codex"],
}
]
}
self.assertEqual(yaml_io.safe_load(yaml_io.safe_dump(data)), data)
def test_filter_matches_name_kind_and_description(self) -> None:
choices = [
SkillChoice("frontend-design", "external", "创建界面"),
SkillChoice("discussion-notes", "owned", "维护讨论笔记"),
]
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, "界面")], ["frontend-design"])
def test_selector_preserves_preselected_items(self) -> None:
choices = [
SkillChoice("already-there", "owned", installed=True),
SkillChoice("new-skill", "external"),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
selected = select_skills(
choices,
wrapper=lambda draw: draw(screen),
)
self.assertEqual(selected, {"already-there"})
def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
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))
def test_external_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)
def test_collection_discovery_ignores_symlinked_skill(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
checkout = root / "checkout"
collection = checkout / "skills"
outside = root / "outside"
collection.mkdir(parents=True)
outside.mkdir()
outside.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
collection.joinpath("escaped").symlink_to(outside, target_is_directory=True)
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "skills",
}
with patch("skiff.registry.external_checkout_path", return_value=checkout):
from skiff.registry import discover_external_skills
self.assertEqual(discover_external_skills("unsafe", entry), {})
class SelectCommandTests(unittest.TestCase):
def test_non_tty_exits_with_add_guidance(self) -> None:
with tempfile.TemporaryDirectory() as temp:
home = Path(temp)
(home / ".skills" / "skills").mkdir(parents=True)
env = os.environ.copy()
env["HOME"] = str(home)
env["PYTHONPATH"] = str(REPO_ROOT)
result = subprocess.run(
[sys.executable, "-m", "skiff", "select"],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("skiff add <name>", result.stderr)
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")
args = argparse.Namespace(
agents=[["codex"]],
global_scope=False,
project=str(project),
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
installed: list[str] = []
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,
"load_registry",
return_value={
"external-one": {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "external-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,
"_is_fully_installed",
side_effect=lambda name, expected, project_root, targets: name == "owned-one",
),
patch.object(
cli,
"select_skills",
return_value={"owned-one", "external-one"},
),
patch.object(
cli,
"_install_skill",
side_effect=lambda name, targets, project_root, **kwargs: installed.append(name),
),
):
cli.cmd_select(args)
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.assertIn("targets:", manifest)
self.assertIn("codex", manifest)
def test_select_rejects_invalid_registry_name_before_rendering(self) -> None:
args = argparse.Namespace(
agents=None,
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
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=[]),
patch.object(
cli,
"load_registry",
return_value={"../../victim": {"repo": "https://example.test/repo.git"}},
),
patch.object(cli, "select_skills") as selector,
):
with self.assertRaisesRegex(SystemExit, "skill 名称无效"):
cli.cmd_select(args)
selector.assert_not_called()
def test_select_expands_registry_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]) -> set[str]:
selected_choices.extend(choices)
return {"waza/think"}
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=[]),
patch.object(
cli,
"load_registry",
return_value={
"waza": {
"repo": "https://example.test/waza.git",
"ref": "main",
"path": "skills",
}
},
),
patch.object(
cli,
"_registry_skill_names",
return_value=["think", "ui"],
),
patch.object(cli, "_list_fully_installed_names", return_value=[]),
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], ["waza/think", "waza/ui"])
self.assertEqual(installed, [("think", "registry:waza")])
def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
skill = root / "source"
skill.mkdir()
skill.joinpath("SKILL.md").write_text("---\\n", encoding="utf-8")
links = {
"cursor": root / "cursor" / "demo",
"claude": root / "claude" / "demo",
}
links["claude"].mkdir(parents=True)
with (
patch.object(cli, "_ensure_source_fetched"),
patch.object(cli, "resolve_skill_source", return_value=(skill, "owned")),
patch.object(
cli,
"agent_skill_dir",
side_effect=lambda target, project_root=None: links[target].parent,
),
):
with self.assertRaises(FileExistsError):
cli._install_skill("demo", ["cursor", "claude"])
self.assertFalse(links["cursor"].exists())
self.assertTrue(links["claude"].is_dir())
if __name__ == "__main__":
unittest.main()