Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 983183d5b3 | |||
| b78126830b | |||
| 3411e54b13 | |||
| b9963b66de |
@@ -97,6 +97,28 @@ skiff fetch 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)
|
||||
|
||||
推荐使用 Vercel CLI 安装第三方 skill:
|
||||
@@ -143,6 +165,7 @@ registry.yaml ←── skiff add / fetch
|
||||
| ---------------- | ---------------------------------- | ------------------------------- |
|
||||
| **Owned** | `skills/<name>/` | 本仓库 commit |
|
||||
| **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` |
|
||||
|
||||
### 非 Skill 资料分类
|
||||
@@ -176,6 +199,8 @@ skills:
|
||||
- name: superpowers
|
||||
source: registry
|
||||
ref: main
|
||||
- name: internal-review
|
||||
source: company
|
||||
|
||||
targets: # 可选,默认 all
|
||||
- cursor
|
||||
|
||||
@@ -10,6 +10,7 @@ cd ~/.skills
|
||||
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
|
||||
|
||||
skiff install declarative-openspec-loop
|
||||
skiff select # 交互式选择并批量安装
|
||||
skiff list
|
||||
skiff status
|
||||
```
|
||||
|
||||
+20
-1
@@ -4,9 +4,28 @@
|
||||
# repo: <git-url>
|
||||
# ref: <branch|tag> (default: main)
|
||||
# path: <subpath> (default: .)
|
||||
# description: <text> (optional, shown by `skiff select`)
|
||||
# tags: (optional)
|
||||
# - <tag>
|
||||
#
|
||||
# Example:
|
||||
# Single-skill example (`path` contains SKILL.md):
|
||||
# example-skills:
|
||||
# repo: https://github.com/example/skills
|
||||
# ref: main
|
||||
# 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
@@ -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
|
||||
# 浏览可用自研 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]` | 安装状态总览 |
|
||||
|
||||
### 项目初始化
|
||||
@@ -70,6 +71,7 @@ skiff bootstrap
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `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 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 fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` |
|
||||
| `skiff add <name> [-g] [-a AGENT...]` | 安装 registry 中的外部 skill(缺失时自动 fetch) |
|
||||
| `skiff fetch <name>` | 克隆或更新外部仓库缓存 |
|
||||
| `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 目标
|
||||
├── skills.py # 自研 skill 发现
|
||||
├── registry.py # registry.yaml 读写
|
||||
├── sources.py # custom source 配置、发现与 Git 管理
|
||||
├── project.py # .skills.yaml 管理
|
||||
├── symlinks.py # 软链创建/检查/修复
|
||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||
@@ -170,7 +230,9 @@ skiff/
|
||||
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
||||
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 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
@@ -1,3 +1,3 @@
|
||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
+618
-36
@@ -16,6 +16,7 @@ from skiff.paths import (
|
||||
ALL_TARGETS,
|
||||
DRAFTS_DIR,
|
||||
EXTERNALS_DIR,
|
||||
CONFIG_FILE,
|
||||
SKILLS_DIR,
|
||||
SKILLS_HOME,
|
||||
TEMPLATE_DIR,
|
||||
@@ -30,15 +31,33 @@ from skiff.project import (
|
||||
resolve_manifest_skill,
|
||||
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 (
|
||||
list_custom_skills,
|
||||
list_owned_skills,
|
||||
owned_skill_path,
|
||||
read_skill_meta,
|
||||
resolve_skill_source,
|
||||
split_skill_spec,
|
||||
skill_description,
|
||||
validate_skill_dir,
|
||||
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.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
|
||||
|
||||
|
||||
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()
|
||||
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:
|
||||
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)
|
||||
if path.exists():
|
||||
if (path / "SKILL.md").is_file() or discover_external_skills(name, entry):
|
||||
return
|
||||
|
||||
repo = entry["repo"]
|
||||
repo = str(entry["repo"])
|
||||
ref = entry.get("ref", "main")
|
||||
dest = EXTERNALS_DIR / name
|
||||
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = external_checkout_path(name, entry)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists():
|
||||
raise SystemExit(
|
||||
f"外部仓库已存在但 skill 路径无效: {external_skill_path(name, entry)}"
|
||||
)
|
||||
_print(f"拉取外部 skill: {name}")
|
||||
subprocess.run(
|
||||
["git", "clone", "--branch", ref, "--", repo, str(dest)],
|
||||
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:
|
||||
_ensure_external_fetched(name)
|
||||
skill_path, _ = resolve_skill_source(name)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=project_root) / name
|
||||
def _registry_skill_names(
|
||||
name: str,
|
||||
entry: dict[str, object] | None = None,
|
||||
) -> list[str]:
|
||||
entry = entry or load_registry().get(name)
|
||||
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 "项目"
|
||||
_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]:
|
||||
@@ -115,6 +249,34 @@ def _list_installed_names(project_root: Path | None, targets: list[str]) -> list
|
||||
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(
|
||||
name: str,
|
||||
targets: list[str],
|
||||
@@ -138,11 +300,18 @@ def cmd_list(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
custom = (
|
||||
{}
|
||||
if args.source in ("owned", "registry")
|
||||
else list_custom_skills(args.source)
|
||||
)
|
||||
|
||||
if args.source in (None, "owned"):
|
||||
_print("自研 (owned):")
|
||||
for name in owned:
|
||||
_print(f" {name}")
|
||||
|
||||
if args.source in (None, "registry"):
|
||||
_print("\n外部 (registry):")
|
||||
if not registry:
|
||||
_print(" (无)")
|
||||
@@ -151,6 +320,13 @@ def cmd_list(args: argparse.Namespace) -> None:
|
||||
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:
|
||||
del args
|
||||
@@ -161,8 +337,14 @@ def cmd_bootstrap(args: argparse.Namespace) -> None:
|
||||
_print("已安装项目 skill 到所有 agent")
|
||||
|
||||
|
||||
def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]:
|
||||
skill_path, _ = resolve_skill_source(name)
|
||||
def _installed_links(
|
||||
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]] = []
|
||||
for target in targets:
|
||||
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))
|
||||
owned = list_owned_skills()
|
||||
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"agents: {', '.join(targets)}\n")
|
||||
|
||||
for name in all_names:
|
||||
kind = "owned" if name in owned else "external"
|
||||
_print(f"[{kind}] {name}")
|
||||
for package in unfetched_registry:
|
||||
_print(f"[registry] {package}\n (未 fetch)\n")
|
||||
|
||||
for source, name in entries:
|
||||
_print(f"[{source}] {name}")
|
||||
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:
|
||||
_print(" (未 fetch)")
|
||||
continue
|
||||
@@ -217,11 +419,23 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
|
||||
if args.list_available:
|
||||
if args.source:
|
||||
cmd_list(argparse.Namespace(source=args.source))
|
||||
else:
|
||||
_print_available_skills()
|
||||
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
|
||||
return
|
||||
|
||||
if args.all:
|
||||
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(["*"])
|
||||
else:
|
||||
@@ -232,9 +446,209 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
|
||||
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)
|
||||
_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:
|
||||
@@ -250,9 +664,32 @@ def cmd_remove(args: argparse.Namespace) -> None:
|
||||
if not names:
|
||||
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
|
||||
for name in names:
|
||||
for name in dict.fromkeys(expanded):
|
||||
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:
|
||||
_print("没有移除任何 skill")
|
||||
@@ -294,7 +731,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
|
||||
entry = registry[args.name]
|
||||
repo = entry["repo"]
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
_warn_deprecated("skiff enable", "skiff add <name>")
|
||||
ensure_skills_home()
|
||||
@@ -318,13 +843,10 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
||||
root = _project_root(args.project)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
|
||||
registry = load_registry()
|
||||
if args.name in registry:
|
||||
extra = {"source": "registry", "ref": registry[args.name].get("ref", "main")}
|
||||
add_skill_to_manifest(manifest_path, args.name, source="registry", extra=extra)
|
||||
else:
|
||||
owned_skill_path(args.name)
|
||||
add_skill_to_manifest(manifest_path, args.name, source="owned")
|
||||
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)
|
||||
@@ -332,8 +854,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
||||
if manifest_targets:
|
||||
targets = [t for t in targets if t in manifest_targets]
|
||||
|
||||
_install_skill(args.name, targets, project_root=root)
|
||||
_print(f"已启用项目 skill: {args.name} @ {root}")
|
||||
_install_skill(name, targets, project_root=root, source=resolved_source)
|
||||
_print(f"已启用项目 skill: {resolved_source}/{name} @ {root}")
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
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
|
||||
create_link(link, skill_path)
|
||||
_print(f"已同步: {link} -> {skill_path}")
|
||||
@@ -471,7 +1000,7 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
issues += 1
|
||||
|
||||
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)
|
||||
if status.ok:
|
||||
continue
|
||||
@@ -491,6 +1020,16 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
_err(f"✗ 外部 skill 未 fetch: {name}")
|
||||
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:
|
||||
_print("\n全部正常")
|
||||
else:
|
||||
@@ -536,7 +1075,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
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_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("--list", dest="list_available", action="store_true", help="列出可用自研 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)
|
||||
|
||||
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(
|
||||
"remove",
|
||||
aliases=["rm", "r"],
|
||||
@@ -589,6 +1137,40 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_fetch.add_argument("name", help="registry 名称")
|
||||
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.add_argument("name")
|
||||
p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
||||
|
||||
@@ -11,6 +11,8 @@ TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||
DRAFTS_DIR = SKILLS_HOME / ".drafts"
|
||||
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
||||
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"
|
||||
|
||||
AGENT_GLOBAL: dict[str, Path] = {
|
||||
|
||||
+8
-3
@@ -65,13 +65,16 @@ def add_skill_to_manifest(
|
||||
file_path = path
|
||||
|
||||
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}
|
||||
if extra:
|
||||
item.update(extra)
|
||||
existing_index = next((i for i, entry in enumerate(entries) if entry["name"] == name), None)
|
||||
if existing_index is None:
|
||||
entries.append(item)
|
||||
elif entries[existing_index] == item:
|
||||
return
|
||||
else:
|
||||
entries[existing_index] = item
|
||||
|
||||
data["skills"] = [_entry_to_yaml(e) for e in entries]
|
||||
save_manifest(file_path, data)
|
||||
@@ -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]:
|
||||
name = entry["name"]
|
||||
source = entry.get("source", "owned")
|
||||
if source == "registry" and entry.get("registry"):
|
||||
source = f"registry:{entry['registry']}"
|
||||
return resolve_skill_source(name, source=source)
|
||||
|
||||
+76
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
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, {})
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
+89
-10
@@ -6,7 +6,12 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
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]:
|
||||
@@ -31,25 +36,99 @@ def owned_skill_path(name: str) -> 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]:
|
||||
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
|
||||
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
||||
ensure_skills_home()
|
||||
name, source = split_skill_spec(name, source)
|
||||
|
||||
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"
|
||||
|
||||
registry = load_registry()
|
||||
if source in (None, "registry") and name in registry:
|
||||
path = external_skill_path(name, registry[name])
|
||||
if not path.exists():
|
||||
raise SystemExit(
|
||||
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
|
||||
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, "external"
|
||||
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():
|
||||
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}")
|
||||
|
||||
|
||||
@@ -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
@@ -236,11 +236,12 @@ def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
first = True
|
||||
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
|
||||
if isinstance(value, (dict, list)):
|
||||
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())
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {_scalar(value)}")
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -106,6 +106,16 @@ skiff add discussion-notes -a cursor -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user