feat: support registry collections and docker publishing
This commit is contained in:
+17
-1
@@ -8,8 +8,24 @@
|
|||||||
# tags: (optional)
|
# tags: (optional)
|
||||||
# - <tag>
|
# - <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
|
||||||
|
|||||||
+7
-3
@@ -92,11 +92,15 @@ 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>` | 克隆或更新外部仓库缓存 |
|
| `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`
|
`registry.yaml` 条目可额外提供 `description` 和 `tags`。`description`
|
||||||
会显示在 `skiff select` 的候选列表中。同一 `repo` 与 `ref` 下的多个 skill
|
会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含
|
||||||
共享一份 Git checkout,再通过各自的 `path` 定位目录。
|
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
|
||||||
|
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add <name>`
|
||||||
|
安装全部,`skiff select` 则展开为 `<name>/<skill>` 供分别勾选。同一
|
||||||
|
`repo` 与 `ref` 共享一份 Git checkout。
|
||||||
|
|
||||||
### 交互式批量安装
|
### 交互式批量安装
|
||||||
|
|
||||||
|
|||||||
+257
-35
@@ -32,6 +32,7 @@ from skiff.project import (
|
|||||||
save_manifest,
|
save_manifest,
|
||||||
)
|
)
|
||||||
from skiff.registry import (
|
from skiff.registry import (
|
||||||
|
discover_external_skills,
|
||||||
external_checkout_path,
|
external_checkout_path,
|
||||||
external_skill_path,
|
external_skill_path,
|
||||||
load_registry,
|
load_registry,
|
||||||
@@ -42,6 +43,7 @@ from skiff.skills import (
|
|||||||
list_custom_skills,
|
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,
|
split_skill_spec,
|
||||||
skill_description,
|
skill_description,
|
||||||
@@ -89,6 +91,7 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None
|
|||||||
|
|
||||||
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
||||||
name, source = split_skill_spec(name, source)
|
name, source = split_skill_spec(name, source)
|
||||||
|
registry = load_registry()
|
||||||
sources = load_sources()
|
sources = load_sources()
|
||||||
if source in sources:
|
if source in sources:
|
||||||
root = source_skills_root(source, sources[source])
|
root = source_skills_root(source, sources[source])
|
||||||
@@ -96,19 +99,38 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
|||||||
_print(f"拉取 source: {source}")
|
_print(f"拉取 source: {source}")
|
||||||
fetch_source(source, sources[source])
|
fetch_source(source, sources[source])
|
||||||
return
|
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"):
|
if source not in (None, "registry"):
|
||||||
return
|
return
|
||||||
|
|
||||||
registry = load_registry()
|
|
||||||
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 / "SKILL.md").is_file():
|
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 = external_checkout_path(name, entry)
|
dest = external_checkout_path(name, entry)
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -121,6 +143,62 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
|||||||
["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 _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(
|
def _install_skill(
|
||||||
@@ -184,6 +262,21 @@ def _list_fully_installed_names(
|
|||||||
return sorted(set.intersection(*installed_by_target))
|
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],
|
||||||
@@ -266,12 +359,26 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
custom = list_custom_skills()
|
custom = list_custom_skills()
|
||||||
entries = [("owned", name) for name in owned]
|
entries = [("owned", name) for name in owned]
|
||||||
entries.extend(("registry", name) for name in registry)
|
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)
|
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 package in unfetched_registry:
|
||||||
|
_print(f"[registry] {package}\n (未 fetch)\n")
|
||||||
|
|
||||||
for source, name in entries:
|
for source, name in entries:
|
||||||
_print(f"[{source}] {name}")
|
_print(f"[{source}] {name}")
|
||||||
try:
|
try:
|
||||||
@@ -339,21 +446,60 @@ 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 = [
|
||||||
skill_name, source = split_skill_spec(name, args.source)
|
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)
|
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(
|
resolved_source = _install_skill(
|
||||||
skill_name,
|
skill_name,
|
||||||
targets,
|
targets,
|
||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
if project_root is not None:
|
if manifest_path is not None:
|
||||||
|
manifest_source, extra = _manifest_source_details(resolved_source)
|
||||||
add_skill_to_manifest(
|
add_skill_to_manifest(
|
||||||
project_root / ".skills.yaml",
|
manifest_path,
|
||||||
skill_name,
|
skill_name,
|
||||||
source=resolved_source,
|
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:
|
def cmd_select(args: argparse.Namespace) -> None:
|
||||||
@@ -365,38 +511,67 @@ def cmd_select(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||||
project_root = None if args.global_scope else _project_root(args.project)
|
project_root = None if args.global_scope else _project_root(args.project)
|
||||||
installed = set(_list_fully_installed_names(project_root, targets))
|
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
owned_names = list_owned_skills()
|
owned_names = list_owned_skills()
|
||||||
for name in [*owned_names, *registry]:
|
for name in [*owned_names, *registry]:
|
||||||
validate_skill_name(name)
|
validate_skill_name(name)
|
||||||
collisions = set(owned_names) & set(registry)
|
|
||||||
for name in sorted(collisions):
|
|
||||||
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {name}")
|
|
||||||
|
|
||||||
choices = [
|
choices = [
|
||||||
SkillChoice(
|
SkillChoice(
|
||||||
name=name,
|
name=name,
|
||||||
kind="owned",
|
kind="owned",
|
||||||
description=skill_description(name) or "",
|
description=skill_description(name) or "",
|
||||||
installed=name in installed,
|
installed=_is_fully_installed(
|
||||||
|
name,
|
||||||
|
SKILLS_DIR / name,
|
||||||
|
project_root,
|
||||||
|
targets,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for name in owned_names
|
for name in owned_names
|
||||||
]
|
]
|
||||||
choices.extend(
|
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(
|
SkillChoice(
|
||||||
name=name,
|
name=package,
|
||||||
kind="external",
|
kind="external",
|
||||||
description=str(entry.get("description", "")),
|
description=str(entry.get("description", "")),
|
||||||
installed=name in installed,
|
installed=_is_fully_installed(
|
||||||
|
package,
|
||||||
|
root,
|
||||||
|
project_root,
|
||||||
|
targets,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for name, entry in registry.items()
|
|
||||||
if name not in collisions
|
|
||||||
)
|
)
|
||||||
choice_sources = {
|
choice_requests[package] = (package, "registry")
|
||||||
**{name: "owned" for name in owned_names},
|
continue
|
||||||
**{name: "registry" for name in registry if name not in collisions},
|
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:
|
try:
|
||||||
selected = select_skills(choices)
|
selected = select_skills(choices)
|
||||||
@@ -406,18 +581,31 @@ def cmd_select(args: argparse.Namespace) -> None:
|
|||||||
_print("已取消,未修改环境")
|
_print("已取消,未修改环境")
|
||||||
return
|
return
|
||||||
|
|
||||||
names = sorted(selected - installed)
|
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]] = []
|
failures: list[tuple[str, str]] = []
|
||||||
manifest_path = project_root / ".skills.yaml" if project_root else None
|
manifest_path = project_root / ".skills.yaml" if project_root else None
|
||||||
successful = set(selected & installed)
|
successful = set(selected & selected_installed_keys)
|
||||||
for name in names:
|
for name in names:
|
||||||
|
skill_name, source = choice_requests[name]
|
||||||
try:
|
try:
|
||||||
_install_skill(
|
_install_skill(
|
||||||
name,
|
skill_name,
|
||||||
targets,
|
targets,
|
||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
source=choice_sources[name],
|
source=source,
|
||||||
)
|
)
|
||||||
successful.add(name)
|
successful.add(name)
|
||||||
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
|
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
|
||||||
@@ -427,21 +615,29 @@ def cmd_select(args: argparse.Namespace) -> None:
|
|||||||
if manifest_path is not None:
|
if manifest_path is not None:
|
||||||
entry_targets = targets if args.agents else None
|
entry_targets = targets if args.agents else None
|
||||||
for name in sorted(successful):
|
for name in sorted(successful):
|
||||||
entry = registry.get(name)
|
skill_name, source = choice_requests[name]
|
||||||
if name in owned_names:
|
if source == "owned":
|
||||||
add_skill_to_manifest(
|
add_skill_to_manifest(
|
||||||
manifest_path,
|
manifest_path,
|
||||||
name,
|
skill_name,
|
||||||
source="owned",
|
source="owned",
|
||||||
extra={"targets": entry_targets} if entry_targets else None,
|
extra={"targets": entry_targets} if entry_targets else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
extra = {"ref": entry.get("ref", "main")}
|
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:
|
if entry_targets:
|
||||||
extra["targets"] = entry_targets
|
extra["targets"] = entry_targets
|
||||||
add_skill_to_manifest(
|
add_skill_to_manifest(
|
||||||
manifest_path,
|
manifest_path,
|
||||||
name,
|
skill_name,
|
||||||
source="registry",
|
source="registry",
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
@@ -468,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")
|
||||||
@@ -666,7 +885,10 @@ 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"]
|
||||||
_ensure_source_fetched(name, entry.get("source"))
|
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)
|
||||||
skill_targets = targets
|
skill_targets = targets
|
||||||
if entry.get("targets"):
|
if entry.get("targets"):
|
||||||
|
|||||||
@@ -101,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)
|
||||||
|
|||||||
@@ -56,3 +56,52 @@ def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
|||||||
f"registry 条目 {name!r} 的 path 超出外部仓库: {subpath!r}"
|
f"registry 条目 {name!r} 的 path 超出外部仓库: {subpath!r}"
|
||||||
) from exc
|
) from exc
|
||||||
return skill_path
|
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]
|
||||||
|
|||||||
+24
-2
@@ -6,7 +6,11 @@ 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
|
from skiff.sources import list_source_skills, load_sources, source_skills_root
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +70,25 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
|||||||
return owned, "owned"
|
return owned, "owned"
|
||||||
|
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
|
sources = load_sources()
|
||||||
|
registry_collection = None
|
||||||
|
if source and source.startswith("registry:"):
|
||||||
|
registry_collection = source.split(":", 1)[1]
|
||||||
|
elif (
|
||||||
|
source
|
||||||
|
and source not in ("owned", "registry")
|
||||||
|
and source in registry
|
||||||
|
and source not in sources
|
||||||
|
):
|
||||||
|
registry_collection = source
|
||||||
|
if registry_collection:
|
||||||
|
path = external_collection_skill_path(
|
||||||
|
registry_collection,
|
||||||
|
name,
|
||||||
|
registry[registry_collection],
|
||||||
|
)
|
||||||
|
return path, f"registry:{registry_collection}"
|
||||||
|
|
||||||
if source == "registry":
|
if source == "registry":
|
||||||
if name not in registry:
|
if name not in registry:
|
||||||
raise SystemExit(f"registry 中找不到 skill: {name}")
|
raise SystemExit(f"registry 中找不到 skill: {name}")
|
||||||
@@ -77,7 +100,6 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
|||||||
)
|
)
|
||||||
return path, "registry"
|
return path, "registry"
|
||||||
|
|
||||||
sources = load_sources()
|
|
||||||
if source:
|
if source:
|
||||||
if source not in sources:
|
if source not in sources:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -137,6 +137,35 @@ class CustomSourceTests(unittest.TestCase):
|
|||||||
self.assertIn("owned/code-review", result.stderr)
|
self.assertIn("owned/code-review", result.stderr)
|
||||||
self.assertIn("company/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:
|
def test_sync_uses_manifest_source(self) -> None:
|
||||||
company = self.home / "company"
|
company = self.home / "company"
|
||||||
expected = write_skill(company / "skills", "code-review")
|
expected = write_skill(company / "skills", "code-review")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -76,6 +76,27 @@ class SelectorTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(SystemExit, "超出外部仓库"):
|
with self.assertRaisesRegex(SystemExit, "超出外部仓库"):
|
||||||
external_skill_path("unsafe-skill", entry)
|
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):
|
class SelectCommandTests(unittest.TestCase):
|
||||||
def test_non_tty_exits_with_add_guidance(self) -> None:
|
def test_non_tty_exits_with_add_guidance(self) -> None:
|
||||||
@@ -101,6 +122,9 @@ class SelectCommandTests(unittest.TestCase):
|
|||||||
def test_project_selection_installs_new_and_records_all_selected(self) -> None:
|
def test_project_selection_installs_new_and_records_all_selected(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
project = Path(temp)
|
project = Path(temp)
|
||||||
|
external = project / "external-one"
|
||||||
|
external.mkdir()
|
||||||
|
external.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
|
||||||
args = argparse.Namespace(
|
args = argparse.Namespace(
|
||||||
agents=[["codex"]],
|
agents=[["codex"]],
|
||||||
global_scope=False,
|
global_scope=False,
|
||||||
@@ -130,7 +154,14 @@ class SelectCommandTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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, "_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(
|
patch.object(
|
||||||
cli,
|
cli,
|
||||||
"select_skills",
|
"select_skills",
|
||||||
@@ -182,6 +213,61 @@ class SelectCommandTests(unittest.TestCase):
|
|||||||
|
|
||||||
selector.assert_not_called()
|
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:
|
def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
root = Path(temp)
|
root = Path(temp)
|
||||||
|
|||||||
Reference in New Issue
Block a user