feat: support registry collections and docker publishing
This commit is contained in:
+269
-47
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user