feat: support registry collections and docker publishing

This commit is contained in:
2026-07-27 23:25:46 +08:00
parent b78126830b
commit 983183d5b3
13 changed files with 891 additions and 53 deletions
+269 -47
View File
@@ -32,6 +32,7 @@ from skiff.project import (
save_manifest,
)
from skiff.registry import (
discover_external_skills,
external_checkout_path,
external_skill_path,
load_registry,
@@ -42,6 +43,7 @@ from skiff.skills import (
list_custom_skills,
list_owned_skills,
owned_skill_path,
read_skill_meta,
resolve_skill_source,
split_skill_spec,
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:
name, source = split_skill_spec(name, source)
registry = load_registry()
sources = load_sources()
if source in sources:
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}")
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
registry = load_registry()
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 / "SKILL.md").is_file():
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 = external_checkout_path(name, entry)
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)],
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(
@@ -184,6 +262,21 @@ def _list_fully_installed_names(
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],
@@ -266,12 +359,26 @@ def cmd_status(args: argparse.Namespace) -> None:
registry = load_registry()
custom = list_custom_skills()
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)
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
_print(f"agents: {', '.join(targets)}\n")
for package in unfetched_registry:
_print(f"[registry] {package}\n (未 fetch)\n")
for source, name in entries:
_print(f"[{source}] {name}")
try:
@@ -339,22 +446,61 @@ def cmd_add(args: argparse.Namespace) -> None:
project_root = None if args.global_scope else _project_root(args.project)
for name in names:
skill_name, source = split_skill_spec(name, args.source)
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)
resolved_source = _install_skill(
skill_name,
targets,
project_root=project_root,
source=source,
)
if project_root is not None:
add_skill_to_manifest(
project_root / ".skills.yaml",
skill_name,
source=resolved_source,
_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()
@@ -365,38 +511,67 @@ def cmd_select(args: argparse.Namespace) -> None:
targets = resolve_agent_args(flatten_agent_args(args.agents))
project_root = None if args.global_scope else _project_root(args.project)
installed = set(_list_fully_installed_names(project_root, targets))
registry = load_registry()
owned_names = list_owned_skills()
for name in [*owned_names, *registry]:
validate_skill_name(name)
collisions = set(owned_names) & set(registry)
for name in sorted(collisions):
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {name}")
choices = [
SkillChoice(
name=name,
kind="owned",
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
]
choices.extend(
SkillChoice(
name=name,
kind="external",
description=str(entry.get("description", "")),
installed=name in installed,
)
for name, entry in registry.items()
if name not in collisions
)
choice_sources = {
**{name: "owned" for name in owned_names},
**{name: "registry" for name in registry if name not in collisions},
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)
@@ -406,18 +581,31 @@ def cmd_select(args: argparse.Namespace) -> None:
_print("已取消,未修改环境")
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]] = []
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:
skill_name, source = choice_requests[name]
try:
_install_skill(
name,
skill_name,
targets,
project_root=project_root,
source=choice_sources[name],
source=source,
)
successful.add(name)
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
@@ -427,21 +615,29 @@ def cmd_select(args: argparse.Namespace) -> None:
if manifest_path is not None:
entry_targets = targets if args.agents else None
for name in sorted(successful):
entry = registry.get(name)
if name in owned_names:
skill_name, source = choice_requests[name]
if source == "owned":
add_skill_to_manifest(
manifest_path,
name,
skill_name,
source="owned",
extra={"targets": entry_targets} if entry_targets else None,
)
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:
extra["targets"] = entry_targets
add_skill_to_manifest(
manifest_path,
name,
skill_name,
source="registry",
extra=extra,
)
@@ -468,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")
@@ -666,7 +885,10 @@ def cmd_sync(args: argparse.Namespace) -> None:
for entry in iter_manifest_skills(data):
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_targets = targets
if entry.get("targets"):