From 1371b414bafff3d7f7a6a1b2c5b4b83b7e6dee2b Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 09:16:18 +0100 Subject: [PATCH 01/18] feat: add feature from hf-skills repo --- src/huggingface_hub/cli/_skills.py | 681 +++++++++++++++++++++++++++++ src/huggingface_hub/cli/skills.py | 183 ++++++-- 2 files changed, 822 insertions(+), 42 deletions(-) create mode 100644 src/huggingface_hub/cli/_skills.py diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py new file mode 100644 index 0000000000..d6c1c97bb7 --- /dev/null +++ b/src/huggingface_hub/cli/_skills.py @@ -0,0 +1,681 @@ +"""Internal helpers for marketplace-backed skill installation and updates.""" + +from __future__ import annotations + +import hashlib +import io +import json +import shutil +import subprocess +import tarfile +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath +from typing import Any, Literal +from urllib.parse import urlparse + +from huggingface_hub.errors import CLIError +from huggingface_hub.utils import get_session + + +DEFAULT_SKILLS_REPO = "https://github.com/huggingface/skills" +MARKETPLACE_PATH = ".claude-plugin/marketplace.json" +MARKETPLACE_TIMEOUT = 10 +SKILL_SOURCE_FILENAME = ".skill-source.json" +SKILL_SOURCE_SCHEMA_VERSION = 1 + +SkillSourceOrigin = Literal["remote", "local"] +SkillUpdateStatus = Literal[ + "dirty", + "up_to_date", + "update_available", + "updated", + "unmanaged", + "invalid_metadata", + "source_unreachable", +] + + +@dataclass(frozen=True) +class MarketplaceSkill: + name: str + description: str | None + repo_url: str + repo_ref: str | None + repo_path: str + source_url: str | None = None + + @property + def install_dir_name(self) -> str: + path = PurePosixPath(self.repo_path) + if path.name.lower() == "skill.md": + return path.parent.name or self.name + return path.name or self.name + + +@dataclass(frozen=True) +class InstalledSkillSource: + schema_version: int + installed_via: str + source_origin: SkillSourceOrigin + repo_url: str + repo_ref: str | None + repo_path: str + source_url: str | None + installed_commit: str | None + installed_path_oid: str | None + installed_revision: str + installed_at: str + content_fingerprint: str + + +@dataclass(frozen=True) +class SkillUpdateInfo: + name: str + skill_dir: Path + status: SkillUpdateStatus + detail: str | None = None + current_revision: str | None = None + available_revision: str | None = None + + +def load_marketplace_skills(repo_url: str | None = None) -> list[MarketplaceSkill]: + """Load marketplace skills from the default Hugging Face skills repository or a local override.""" + repo_url = repo_url or DEFAULT_SKILLS_REPO + payload = _load_marketplace_payload(repo_url) + plugins = payload.get("plugins") + if not isinstance(plugins, list): + raise CLIError("Invalid marketplace payload: expected a top-level 'plugins' list.") + + skills: list[MarketplaceSkill] = [] + for plugin in plugins: + if not isinstance(plugin, dict): + continue + name = plugin.get("name") + source = plugin.get("source") + if not isinstance(name, str) or not isinstance(source, str): + continue + description = plugin.get("description") if isinstance(plugin.get("description"), str) else None + skills.append( + MarketplaceSkill( + name=name, + description=description, + repo_url=repo_url, + repo_ref=None, + repo_path=_normalize_repo_path(source), + source_url=None, + ) + ) + return skills + + +def get_marketplace_skill(selector: str, repo_url: str | None = None) -> MarketplaceSkill: + """Resolve a marketplace skill by name.""" + repo_url = repo_url or DEFAULT_SKILLS_REPO + skills = load_marketplace_skills(repo_url) + selected = _select_marketplace_skill(skills, selector) + if selected is None: + raise CLIError( + f"Skill '{selector}' not found in huggingface/skills. " + "Try `hf skills add` to install `hf-cli` or use a known skill name." + ) + return selected + + +def install_marketplace_skill(skill: MarketplaceSkill, destination_root: Path, force: bool = False) -> Path: + """Install a marketplace skill into a local skills directory.""" + destination_root = destination_root.expanduser().resolve() + destination_root.mkdir(parents=True, exist_ok=True) + install_dir = destination_root / skill.install_dir_name + + if install_dir.exists() and not force: + raise FileExistsError(f"Skill already exists: {install_dir}") + + if install_dir.exists(): + with tempfile.TemporaryDirectory(dir=destination_root, prefix=f".{install_dir.name}.install-") as tmp_dir_str: + tmp_dir = Path(tmp_dir_str) + staged_dir = tmp_dir / install_dir.name + _populate_install_dir(skill=skill, install_dir=staged_dir) + _validate_installed_skill_dir(staged_dir) + _atomic_replace_directory(existing_dir=install_dir, staged_dir=staged_dir) + return install_dir + + try: + _populate_install_dir(skill=skill, install_dir=install_dir) + _validate_installed_skill_dir(install_dir) + except Exception: + if install_dir.exists(): + shutil.rmtree(install_dir) + raise + return install_dir + + +def check_for_updates( + roots: list[Path], + selector: str | None = None, +) -> list[SkillUpdateInfo]: + """Check managed skill installs for newer upstream revisions.""" + updates = [_evaluate_update(skill_dir) for skill_dir in _iter_unique_skill_dirs(roots)] + filtered = _filter_updates(updates, selector) + if selector is not None and not filtered: + raise CLIError(f"No installed skills match '{selector}'.") + return filtered + + +def apply_updates( + roots: list[Path], + selector: str | None = None, + force: bool = False, +) -> list[SkillUpdateInfo]: + """Update managed skills in place, skipping dirty installs unless forced.""" + updates = check_for_updates(roots, selector) + results: list[SkillUpdateInfo] = [] + for update in updates: + results.append(_apply_single_update(update, force=force)) + return results + + +def compute_skill_content_fingerprint(skill_dir: Path) -> str: + """Hash installed skill contents while ignoring the provenance sidecar.""" + digest = hashlib.sha256() + root = skill_dir.resolve() + sidecar_path = root / SKILL_SOURCE_FILENAME + + for path in sorted(root.rglob("*")): + if path == sidecar_path or not path.is_file(): + continue + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + + return f"sha256:{digest.hexdigest()}" + + +def read_installed_skill_source(skill_dir: Path) -> tuple[InstalledSkillSource | None, str | None]: + """Read installed skill provenance metadata from the local sidecar file.""" + sidecar_path = skill_dir / SKILL_SOURCE_FILENAME + if not sidecar_path.exists(): + return None, None + try: + payload = json.loads(sidecar_path.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 + return None, f"invalid json: {exc}" + if not isinstance(payload, dict): + return None, "metadata root must be an object" + try: + return _parse_installed_skill_source(payload), None + except ValueError as exc: + return None, str(exc) + + +def write_installed_skill_source(skill_dir: Path, source: InstalledSkillSource) -> None: + payload = { + "schema_version": source.schema_version, + "installed_via": source.installed_via, + "source_origin": source.source_origin, + "repo_url": source.repo_url, + "repo_ref": source.repo_ref, + "repo_path": source.repo_path, + "source_url": source.source_url, + "installed_commit": source.installed_commit, + "installed_path_oid": source.installed_path_oid, + "installed_revision": source.installed_revision, + "installed_at": source.installed_at, + "content_fingerprint": source.content_fingerprint, + } + (skill_dir / SKILL_SOURCE_FILENAME).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _load_marketplace_payload(repo_url: str) -> dict[str, Any]: + if _is_local_repo(repo_url): + marketplace_path = _resolve_local_repo_path(repo_url) / MARKETPLACE_PATH + if not marketplace_path.is_file(): + raise CLIError(f"Marketplace file not found: {marketplace_path}") + try: + payload = json.loads(marketplace_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise CLIError(f"Failed to parse marketplace file: {exc}") from exc + if not isinstance(payload, dict): + raise CLIError("Invalid marketplace payload: expected a JSON object.") + return payload + + raw_url = _raw_github_url(repo_url, "main", MARKETPLACE_PATH) + response = get_session().get(raw_url, follow_redirects=True, timeout=MARKETPLACE_TIMEOUT) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise CLIError("Invalid marketplace payload: expected a JSON object.") + return payload + + +def _select_marketplace_skill(skills: list[MarketplaceSkill], selector: str) -> MarketplaceSkill | None: + selector_lower = selector.strip().lower() + for skill in skills: + if skill.name.lower() == selector_lower: + return skill + return None + + +def _normalize_repo_path(path: str) -> str: + normalized = path.strip() + while normalized.startswith("./"): + normalized = normalized[2:] + normalized = normalized.strip("/") + if not normalized: + raise CLIError("Invalid marketplace entry: empty source path.") + return normalized + + +def _populate_install_dir(skill: MarketplaceSkill, install_dir: Path) -> None: + metadata = _resolve_source_metadata(skill) + install_dir.mkdir(parents=True, exist_ok=True) + + if metadata.source_origin == "local": + _extract_local_git_path( + repo_path=_resolve_local_repo_path(skill.repo_url), + repo_ref=metadata.install_ref, + source_path=skill.repo_path, + install_dir=install_dir, + ) + else: + _extract_remote_github_path( + repo_url=skill.repo_url, + revision=metadata.installed_revision, + source_path=skill.repo_path, + install_dir=install_dir, + ) + + _validate_installed_skill_dir(install_dir) + fingerprint = compute_skill_content_fingerprint(install_dir) + write_installed_skill_source( + install_dir, + InstalledSkillSource( + schema_version=SKILL_SOURCE_SCHEMA_VERSION, + installed_via="marketplace", + source_origin=metadata.source_origin, + repo_url=skill.repo_url, + repo_ref=skill.repo_ref, + repo_path=skill.repo_path, + source_url=skill.source_url, + installed_commit=metadata.installed_commit, + installed_path_oid=None, + installed_revision=metadata.installed_revision, + installed_at=_iso_utc_now(), + content_fingerprint=fingerprint, + ), + ) + + +def _validate_installed_skill_dir(skill_dir: Path) -> None: + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + raise RuntimeError(f"Installed skill is missing SKILL.md: {skill_file}") + skill_file.read_text(encoding="utf-8") + + +@dataclass(frozen=True) +class _ResolvedSourceMetadata: + source_origin: SkillSourceOrigin + install_ref: str + installed_commit: str | None + installed_revision: str + + +def _resolve_source_metadata(skill: MarketplaceSkill) -> _ResolvedSourceMetadata: + if _is_local_repo(skill.repo_url): + repo_path = _resolve_local_repo_path(skill.repo_url) + install_ref = skill.repo_ref or "HEAD" + installed_commit = _git_stdout(repo_path, "rev-parse", install_ref) + return _ResolvedSourceMetadata( + source_origin="local", + install_ref=install_ref, + installed_commit=installed_commit, + installed_revision=installed_commit, + ) + + installed_commit = _git_ls_remote(skill.repo_url, skill.repo_ref) + return _ResolvedSourceMetadata( + source_origin="remote", + install_ref=installed_commit, + installed_commit=installed_commit, + installed_revision=installed_commit, + ) + + +def _extract_local_git_path(repo_path: Path, repo_ref: str, source_path: str, install_dir: Path) -> None: + proc = subprocess.run( + ["git", "-C", str(repo_path), "archive", "--format=tar", repo_ref, source_path], + check=False, + capture_output=True, + text=False, + ) + if proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + raise FileNotFoundError(stderr or f"Path '{source_path}' not found in {repo_ref}.") + _extract_tar_subpath(proc.stdout, source_path=source_path, install_dir=install_dir) + + +def _extract_remote_github_path(repo_url: str, revision: str, source_path: str, install_dir: Path) -> None: + owner, repo = _parse_github_repo(repo_url) + tarball_url = f"https://codeload.github.com/{owner}/{repo}/tar.gz/{revision}" + response = get_session().get(tarball_url, follow_redirects=True, timeout=MARKETPLACE_TIMEOUT) + response.raise_for_status() + _extract_tar_subpath(response.content, source_path=source_path, install_dir=install_dir) + + +def _extract_tar_subpath(tar_bytes: bytes, source_path: str, install_dir: Path) -> None: + """Extract a skill subdirectory from either a git archive or a GitHub tarball. + + Local `git archive` paths start directly at `skills//...`, while GitHub tarballs + include a leading `-/` directory. This helper accepts both layouts. + """ + source_parts = PurePosixPath(source_path).parts + with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as archive: + members = archive.getmembers() + matched = False + for member in members: + relative_parts = _member_relative_parts(member_name=member.name, source_parts=source_parts) + if relative_parts is None: + continue + if not relative_parts: + matched = True + continue + matched = True + relative_path = Path(*relative_parts) + if ".." in relative_path.parts: + raise RuntimeError(f"Invalid path found in archive for {source_path}.") + destination_path = install_dir / relative_path + if member.isdir(): + destination_path.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile(): + continue + destination_path.parent.mkdir(parents=True, exist_ok=True) + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError(f"Failed to extract {member.name}.") + destination_path.write_bytes(extracted.read()) + if not matched: + raise FileNotFoundError(f"Path '{source_path}' not found in source archive.") + + +def _member_relative_parts(member_name: str, source_parts: tuple[str, ...]) -> tuple[str, ...] | None: + path_parts = PurePosixPath(member_name).parts + if tuple(path_parts[: len(source_parts)]) == source_parts: + return path_parts[len(source_parts) :] + if len(path_parts) > len(source_parts) and tuple(path_parts[1 : 1 + len(source_parts)]) == source_parts: + return path_parts[1 + len(source_parts) :] + return None + + +def _atomic_replace_directory(existing_dir: Path, staged_dir: Path) -> None: + backup_dir = staged_dir.parent / f"{existing_dir.name}.backup" + try: + existing_dir.rename(backup_dir) + staged_dir.rename(existing_dir) + shutil.rmtree(backup_dir) + except Exception: + if backup_dir.exists() and not existing_dir.exists(): + backup_dir.rename(existing_dir) + raise + + +def _iter_unique_skill_dirs(roots: list[Path]) -> list[Path]: + seen: set[Path] = set() + discovered: list[Path] = [] + for root in roots: + root = root.expanduser().resolve() + if not root.is_dir(): + continue + for child in sorted(root.iterdir()): + if child.name.startswith("."): + continue + if not child.is_dir() and not child.is_symlink(): + continue + resolved = child.resolve() + if resolved in seen or not resolved.is_dir(): + continue + seen.add(resolved) + discovered.append(resolved) + return discovered + + +def _evaluate_update(skill_dir: Path) -> SkillUpdateInfo: + source, error = read_installed_skill_source(skill_dir) + if source is None: + return SkillUpdateInfo( + name=skill_dir.name, + skill_dir=skill_dir, + status="invalid_metadata" if error is not None else "unmanaged", + detail=error, + ) + + current_revision = source.installed_revision + try: + available_revision = _resolve_available_revision(source) + except Exception as exc: + return SkillUpdateInfo( + name=skill_dir.name, + skill_dir=skill_dir, + status="source_unreachable", + detail=str(exc), + current_revision=current_revision, + ) + + fingerprint = compute_skill_content_fingerprint(skill_dir) + if fingerprint != source.content_fingerprint: + return SkillUpdateInfo( + name=skill_dir.name, + skill_dir=skill_dir, + status="dirty", + detail="local modifications detected", + current_revision=current_revision, + available_revision=available_revision, + ) + + if available_revision == current_revision: + return SkillUpdateInfo( + name=skill_dir.name, + skill_dir=skill_dir, + status="up_to_date", + current_revision=current_revision, + available_revision=available_revision, + ) + + return SkillUpdateInfo( + name=skill_dir.name, + skill_dir=skill_dir, + status="update_available", + detail="update available", + current_revision=current_revision, + available_revision=available_revision, + ) + + +def _apply_single_update(update: SkillUpdateInfo, *, force: bool) -> SkillUpdateInfo: + if update.status in {"up_to_date", "unmanaged", "invalid_metadata", "source_unreachable"}: + return update + if update.status == "dirty" and not force: + return update + + source, error = read_installed_skill_source(update.skill_dir) + if source is None: + detail = error or "missing source metadata" + return SkillUpdateInfo( + name=update.name, + skill_dir=update.skill_dir, + status="invalid_metadata", + detail=detail, + current_revision=update.current_revision, + available_revision=update.available_revision, + ) + + skill = MarketplaceSkill( + name=update.name, + description=None, + repo_url=source.repo_url, + repo_ref=source.repo_ref, + repo_path=source.repo_path, + source_url=source.source_url, + ) + try: + install_marketplace_skill(skill, update.skill_dir.parent, force=True) + refreshed = _evaluate_update(update.skill_dir) + except Exception as exc: + return SkillUpdateInfo( + name=update.name, + skill_dir=update.skill_dir, + status="source_unreachable", + detail=str(exc), + current_revision=update.current_revision, + available_revision=update.available_revision, + ) + + return SkillUpdateInfo( + name=update.name, + skill_dir=update.skill_dir, + status="updated", + detail="updated", + current_revision=update.current_revision, + available_revision=refreshed.current_revision, + ) + + +def _filter_updates(updates: list[SkillUpdateInfo], selector: str | None) -> list[SkillUpdateInfo]: + if selector is None: + return updates + selector_lower = selector.strip().lower() + return [update for update in updates if update.name.lower() == selector_lower] + + +def _resolve_available_revision(source: InstalledSkillSource) -> str: + if source.source_origin == "local": + repo_path = _resolve_local_repo_path(source.repo_url) + return _git_stdout(repo_path, "rev-parse", source.repo_ref or "HEAD") + return _git_ls_remote(source.repo_url, source.repo_ref) + + +def _parse_installed_skill_source(payload: dict[str, Any]) -> InstalledSkillSource: + if payload.get("schema_version") != SKILL_SOURCE_SCHEMA_VERSION: + raise ValueError(f"unsupported schema_version: {payload.get('schema_version')}") + repo_url = payload.get("repo_url") + repo_path = payload.get("repo_path") + source_origin = payload.get("source_origin") + installed_via = payload.get("installed_via") + installed_revision = payload.get("installed_revision") + installed_at = payload.get("installed_at") + + if not isinstance(repo_url, str) or not repo_url: + raise ValueError("missing repo_url") + if not isinstance(repo_path, str) or not repo_path: + raise ValueError("missing repo_path") + if source_origin not in {"local", "remote"}: + raise ValueError("invalid source_origin") + if not isinstance(installed_via, str) or not installed_via: + raise ValueError("missing installed_via") + if not isinstance(installed_revision, str) or not installed_revision: + raise ValueError("missing installed_revision") + if not isinstance(installed_at, str) or not installed_at: + raise ValueError("missing installed_at") + + repo_ref = payload.get("repo_ref") + source_url = payload.get("source_url") + installed_commit = payload.get("installed_commit") + installed_path_oid = payload.get("installed_path_oid") + content_fingerprint = payload.get("content_fingerprint") + + if repo_ref is not None and not isinstance(repo_ref, str): + raise ValueError("invalid repo_ref") + if source_url is not None and not isinstance(source_url, str): + raise ValueError("invalid source_url") + if installed_commit is not None and not isinstance(installed_commit, str): + raise ValueError("invalid installed_commit") + if installed_path_oid is not None and not isinstance(installed_path_oid, str): + raise ValueError("invalid installed_path_oid") + if not isinstance(content_fingerprint, str) or not content_fingerprint: + raise ValueError("missing content_fingerprint") + + return InstalledSkillSource( + schema_version=SKILL_SOURCE_SCHEMA_VERSION, + installed_via=installed_via, + source_origin=source_origin, + repo_url=repo_url, + repo_ref=repo_ref, + repo_path=repo_path, + source_url=source_url, + installed_commit=installed_commit, + installed_path_oid=installed_path_oid, + installed_revision=installed_revision, + installed_at=installed_at, + content_fingerprint=content_fingerprint, + ) + + +def _is_local_repo(repo_url: str) -> bool: + parsed = urlparse(repo_url) + return parsed.scheme in {"", "file"} + + +def _resolve_local_repo_path(repo_url: str) -> Path: + parsed = urlparse(repo_url) + if parsed.scheme == "file": + return Path(parsed.path).expanduser().resolve() + return Path(repo_url).expanduser().resolve() + + +def _raw_github_url(repo_url: str, revision: str, path: str) -> str: + owner, repo = _parse_github_repo(repo_url) + return f"https://raw.githubusercontent.com/{owner}/{repo}/{revision}/{path}" + + +def _parse_github_repo(repo_url: str) -> tuple[str, str]: + parsed = urlparse(repo_url) + if parsed.netloc not in {"github.com", "www.github.com"}: + raise CLIError(f"Unsupported skills repository URL: {repo_url}") + parts = [part for part in parsed.path.strip("/").split("/") if part] + if len(parts) < 2: + raise CLIError(f"Unsupported skills repository URL: {repo_url}") + repo = parts[1] + if repo.endswith(".git"): + repo = repo[:-4] + return parts[0], repo + + +def _git_stdout(repo_path: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(repo_path), *args], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() or proc.stdout.strip() + raise CLIError(stderr or f"git {' '.join(args)} failed") + return proc.stdout.strip() + + +def _git_ls_remote(repo_url: str, repo_ref: str | None) -> str: + ref = repo_ref or "HEAD" + proc = subprocess.run( + ["git", "ls-remote", repo_url, ref], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() or proc.stdout.strip() + raise CLIError(stderr or f"Unable to resolve {ref} for {repo_url}") + for line in proc.stdout.splitlines(): + parts = line.split() + if parts: + return parts[0] + raise CLIError(f"Unable to resolve {ref} for {repo_url}") + + +def _iso_utc_now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index 448d4a812f..745dff7b59 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -37,7 +37,6 @@ hf skills add --claude --force """ -import os import shutil from pathlib import Path from typing import Annotated, Optional @@ -46,6 +45,9 @@ from click import Command, Context, Group from typer.main import get_command +from huggingface_hub.errors import CLIError + +from . import _skills from ._cli_utils import typer_factory @@ -276,32 +278,57 @@ def _remove_existing(path: Path, force: bool) -> None: path.unlink() -def _install_to(skills_dir: Path, force: bool) -> Path: - """Download and install the skill files into a skills directory. Returns the installed path.""" - skills_dir = skills_dir.expanduser().resolve() - skills_dir.mkdir(parents=True, exist_ok=True) - dest = skills_dir / DEFAULT_SKILL_ID - - _remove_existing(dest, force) - dest.mkdir() - - (dest / "SKILL.md").write_text(build_skill_md(), encoding="utf-8") +def _install_to(skills_dir: Path, skill_name: str, force: bool) -> Path: + """Install a marketplace skill into a skills directory. Returns the installed path.""" + skill = _skills.get_marketplace_skill(skill_name) + try: + return _skills.install_marketplace_skill(skill, skills_dir, force=force) + except FileExistsError as exc: + raise SystemExit(f"{exc}\nRe-run with --force to overwrite.") from exc - return dest - -def _create_symlink(agent_skills_dir: Path, central_skill_path: Path, force: bool) -> Path: +def _create_symlink(agent_skills_dir: Path, skill_name: str, central_skill_path: Path, force: bool) -> Path: """Create a relative symlink from agent directory to the central skill location.""" agent_skills_dir = agent_skills_dir.expanduser().resolve() agent_skills_dir.mkdir(parents=True, exist_ok=True) - link_path = agent_skills_dir / DEFAULT_SKILL_ID + link_path = agent_skills_dir / skill_name _remove_existing(link_path, force) - link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir)) + link_path.symlink_to(central_skill_path.relative_to(agent_skills_dir, walk_up=True)) return link_path +def _resolve_update_roots( + *, + claude: bool, + codex: bool, + cursor: bool, + opencode: bool, + global_: bool, + dest: Optional[Path], +) -> list[Path]: + if dest is not None: + if claude or codex or cursor or opencode or global_: + raise CLIError("--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global.") + return [dest.expanduser().resolve()] + + targets_dict = GLOBAL_TARGETS if global_ else LOCAL_TARGETS + roots: list[Path] = [CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL] + if not any([claude, codex, cursor, opencode]): + roots.extend(targets_dict.values()) + else: + if claude: + roots.append(targets_dict["claude"]) + if codex: + roots.append(targets_dict["codex"]) + if cursor: + roots.append(targets_dict["cursor"]) + if opencode: + roots.append(targets_dict["opencode"]) + return [root.expanduser().resolve() for root in roots] + + @skills_cli.command("preview") def skills_preview() -> None: """Print the generated SKILL.md to stdout.""" @@ -318,6 +345,10 @@ def skills_preview() -> None: ], ) def skills_add( + name: Annotated[ + str, + typer.Argument(help="Marketplace skill name.", show_default=False), + ] = DEFAULT_SKILL_ID, claude: Annotated[bool, typer.Option("--claude", help="Install for Claude.")] = False, codex: Annotated[bool, typer.Option("--codex", help="Install for Codex.")] = False, cursor: Annotated[bool, typer.Option("--cursor", help="Install for Cursor.")] = False, @@ -349,31 +380,99 @@ def skills_add( Default location is in the current directory (.agents/skills) or user-level (~/.agents/skills). If custom agents are specified (e.g. --claude --codex --cursor --opencode, etc), the skill will be symlinked to the agent's skills directory. """ - if dest: - if claude or codex or cursor or opencode or global_: - print("--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global.") - raise typer.Exit(code=1) - skill_dest = _install_to(dest, force) - print(f"Installed '{DEFAULT_SKILL_ID}' to {skill_dest}") - return + try: + if dest: + if claude or codex or cursor or opencode or global_: + print("--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global.") + raise typer.Exit(code=1) + skill_dest = _install_to(dest, name, force) + print(f"Installed '{name}' to {skill_dest}") + return + + # Install to central location + central_path = CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL + central_skill_path = _install_to(central_path, name, force) + print(f"Installed '{name}' to central location: {central_skill_path}") + + # Create symlinks in agent directories + targets_dict = GLOBAL_TARGETS if global_ else LOCAL_TARGETS + agent_targets: list[Path] = [] + if claude: + agent_targets.append(targets_dict["claude"]) + if codex: + agent_targets.append(targets_dict["codex"]) + if cursor: + agent_targets.append(targets_dict["cursor"]) + if opencode: + agent_targets.append(targets_dict["opencode"]) + + for agent_target in agent_targets: + link_path = _create_symlink(agent_target, name, central_skill_path, force) + print(f"Created symlink: {link_path}") + except CLIError as exc: + print(str(exc)) + raise typer.Exit(code=1) from exc - # Install to central location - central_path = CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL - central_skill_path = _install_to(central_path, force) - print(f"Installed '{DEFAULT_SKILL_ID}' to central location: {central_skill_path}") - # Create symlinks in agent directories - targets_dict = GLOBAL_TARGETS if global_ else LOCAL_TARGETS - agent_targets: list[Path] = [] - if claude: - agent_targets.append(targets_dict["claude"]) - if codex: - agent_targets.append(targets_dict["codex"]) - if cursor: - agent_targets.append(targets_dict["cursor"]) - if opencode: - agent_targets.append(targets_dict["opencode"]) - - for agent_target in agent_targets: - link_path = _create_symlink(agent_target, central_skill_path, force) - print(f"Created symlink: {link_path}") +@skills_cli.command( + "update", + examples=[ + "hf skills update", + "hf skills update hf-cli", + "hf skills update gradio --dest=~/my-skills", + "hf skills update --claude --force", + ], +) +def skills_update( + name: Annotated[ + Optional[str], + typer.Argument(help="Optional installed skill name to update.", show_default=False), + ] = None, + claude: Annotated[bool, typer.Option("--claude", help="Update skills installed for Claude.")] = False, + codex: Annotated[bool, typer.Option("--codex", help="Update skills installed for Codex.")] = False, + cursor: Annotated[bool, typer.Option("--cursor", help="Update skills installed for Cursor.")] = False, + opencode: Annotated[bool, typer.Option("--opencode", help="Update skills installed for OpenCode.")] = False, + global_: Annotated[ + bool, + typer.Option( + "--global", + "-g", + help="Use global skills directories instead of the current project.", + ), + ] = False, + dest: Annotated[ + Optional[Path], + typer.Option( + help="Update skills in a custom skills directory.", + ), + ] = None, + force: Annotated[ + bool, + typer.Option( + "--force", + help="Overwrite local modifications when updating a skill.", + ), + ] = False, +) -> None: + """Update installed marketplace-managed skills.""" + try: + roots = _resolve_update_roots( + claude=claude, + codex=codex, + cursor=cursor, + opencode=opencode, + global_=global_, + dest=dest, + ) + + results = _skills.apply_updates(roots, selector=name, force=force) + if not results: + print("No installed skills found.") + return + + for result in results: + detail = f" ({result.detail})" if result.detail else "" + print(f"{result.name}: {result.status}{detail}") + except CLIError as exc: + print(str(exc)) + raise typer.Exit(code=1) from exc From c860505c365701cf2a89350fad2a8e81f6b74889 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 09:16:26 +0100 Subject: [PATCH 02/18] docs: add update to docs --- docs/source/en/package_reference/cli.md | 43 ++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index 6ba53b1db4..f4a33de6a2 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3120,6 +3120,7 @@ $ hf skills [OPTIONS] COMMAND [ARGS]... * `add`: Download a skill and install it for an AI... * `preview`: Print the generated SKILL.md to stdout. +* `update`: Update installed skills. ### `hf skills add` @@ -3131,9 +3132,13 @@ If custom agents are specified (e.g. --claude --codex --cursor --opencode, etc), **Usage**: ```console -$ hf skills add [OPTIONS] +$ hf skills add [OPTIONS] [NAME] ``` +**Arguments**: + +* `[NAME]`: Hugging Face skill name. + **Options**: * `--claude`: Install for Claude. @@ -3170,6 +3175,42 @@ $ hf skills preview [OPTIONS] * `--help`: Show this message and exit. +### `hf skills update` + +Update installed marketplace-managed skills. + +**Usage**: + +```console +$ hf skills update [OPTIONS] [NAME] +``` + +**Arguments**: + +* `[NAME]`: Optional installed skill name to update. + +**Options**: + +* `--claude`: Update skills installed for Claude. +* `--codex`: Update skills installed for Codex. +* `--cursor`: Update skills installed for Cursor. +* `--opencode`: Update skills installed for OpenCode. +* `-g, --global`: Use global skills directories instead of the current project. +* `--dest PATH`: Update skills in a custom skills directory. +* `--force`: Overwrite local modifications when updating a skill. +* `--help`: Show this message and exit. + +Examples + $ hf skills update + $ hf skills update hf-cli + $ hf skills update gradio --dest=~/my-skills + $ hf skills update --claude --force + +Learn more + Use `hf --help` for more information about a command. + Read the documentation at https://huggingface.co/docs/huggingface_hub/en/guides/cli + + ## `hf spaces` Interact with spaces on the Hub. From 70571a96975de7b9f62d9a34c3ebbe7cb6abcc5a Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 09:16:46 +0100 Subject: [PATCH 03/18] test: use subprocess to test --- tests/test_cli.py | 261 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 15d121f42f..1fa08e009c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ import json import os +import subprocess import warnings from contextlib import contextmanager from pathlib import Path @@ -2899,3 +2900,263 @@ def test_collect_leaf_commands_finds_deeply_nested(self) -> None: leaf_paths = [" ".join(path) for path, _ in leaves] assert any("jobs scheduled run" in p for p in leaf_paths) assert any("jobs uv run" in p for p in leaf_paths) + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _commit_all(repo: Path, message: str) -> str: + _git(repo, "add", ".") + _git(repo, "commit", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +def _create_skills_repo(root: Path, descriptions: dict[str, str], bodies: dict[str, str] | None = None) -> Path: + repo = root / "skills-repo" + repo.mkdir(parents=True) + subprocess.run(["git", "init", str(repo)], check=True, capture_output=True, text=True) + _git(repo, "config", "user.email", "tests@example.com") + _git(repo, "config", "user.name", "Test User") + + bodies = bodies or {} + plugins = [] + for name, description in descriptions.items(): + skill_dir = repo / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + skill_dir.joinpath("SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n\n{bodies.get(name, f'# {name}')}\n", + encoding="utf-8", + ) + skill_dir.joinpath("notes.txt").write_text(f"{name} helper file\n", encoding="utf-8") + plugins.append( + { + "name": name, + "source": f"./skills/{name}", + "skills": "./", + "description": description, + } + ) + + marketplace = repo / ".claude-plugin" / "marketplace.json" + marketplace.parent.mkdir(parents=True, exist_ok=True) + marketplace.write_text(json.dumps({"plugins": plugins}, indent=2), encoding="utf-8") + _commit_all(repo, "initial") + return repo + + +class TestSkillsMarketplaceCLI: + def test_add_defaults_to_hf_cli(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"hf-cli": "HF CLI skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + + result = runner.invoke(app, ["skills", "add", "--dest", str(tmp_path / "managed-skills")]) + + assert result.exit_code == 0, result.output + assert (tmp_path / "managed-skills" / "hf-cli" / "SKILL.md").exists() + assert "Installed 'hf-cli'" in result.stdout + + def test_add_named_skill_to_dest(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"hf-cli": "HF CLI skill", "gradio": "Gradio skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + + result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(tmp_path / "managed-skills")]) + + assert result.exit_code == 0, result.output + skill_dir = tmp_path / "managed-skills" / "gradio" + assert skill_dir.joinpath("SKILL.md").exists() + assert skill_dir.joinpath("notes.txt").exists() + assert "Installed 'gradio'" in result.stdout + + def test_add_named_skill_for_assistant_creates_symlink( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["skills", "add", "gradio", "--claude"]) + + assert result.exit_code == 0, result.output + central = tmp_path / ".agents" / "skills" / "gradio" + link = tmp_path / ".claude" / "skills" / "gradio" + assert central.joinpath("SKILL.md").exists() + assert link.is_symlink() + assert link.resolve() == central.resolve() + + def test_add_unknown_skill_fails_cleanly(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"hf-cli": "HF CLI skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + + result = runner.invoke(app, ["skills", "add", "missing", "--dest", str(tmp_path / "managed-skills")]) + + assert result.exit_code == 1 + assert "Skill 'missing' not found in huggingface/skills" in result.output + + def test_add_existing_skill_without_force_reports_hint( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + dest = tmp_path / "managed-skills" + + first = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + second = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + + assert first.exit_code == 0, first.output + assert second.exit_code == 1 + assert "Skill already exists:" in second.output + assert "Re-run with --force to overwrite." in second.output + + def test_add_force_overwrites_existing_skill(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "remote version"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + dest = tmp_path / "managed-skills" + + first = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + assert first.exit_code == 0, first.output + skill_file = dest / "gradio" / "SKILL.md" + skill_file.write_text("---\nname: gradio\ndescription: local\n---\n\nlocal override\n", encoding="utf-8") + + forced = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest), "--force"]) + + assert forced.exit_code == 0, forced.output + assert "remote version" in skill_file.read_text(encoding="utf-8") + assert "local override" not in skill_file.read_text(encoding="utf-8") + + def test_add_writes_source_metadata(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + dest = tmp_path / "managed-skills" + + result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + + assert result.exit_code == 0, result.output + payload = json.loads((dest / "gradio" / ".skill-source.json").read_text(encoding="utf-8")) + assert payload["repo_url"] == str(repo) + assert payload["repo_path"] == "skills/gradio" + assert payload["installed_revision"] == _git(repo, "rev-parse", "HEAD") + + def test_update_reports_up_to_date(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + dest = tmp_path / "managed-skills" + add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + assert add_result.exit_code == 0, add_result.output + + result = runner.invoke(app, ["skills", "update", "--dest", str(dest)]) + + assert result.exit_code == 0, result.output + assert "gradio: up_to_date" in result.stdout + + def test_update_detects_newer_revision_and_refreshes_files( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + dest = tmp_path / "managed-skills" + add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + assert add_result.exit_code == 0, add_result.output + + (repo / "skills" / "gradio" / "SKILL.md").write_text( + "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", + encoding="utf-8", + ) + _commit_all(repo, "update gradio") + + result = runner.invoke(app, ["skills", "update", "gradio", "--dest", str(dest)]) + + assert result.exit_code == 0, result.output + assert "gradio: updated" in result.stdout + assert "v2" in (dest / "gradio" / "SKILL.md").read_text(encoding="utf-8") + + def test_update_skips_dirty_install_without_force(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + dest = tmp_path / "managed-skills" + add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + assert add_result.exit_code == 0, add_result.output + skill_file = dest / "gradio" / "SKILL.md" + skill_file.write_text(skill_file.read_text(encoding="utf-8") + "\nlocal edit\n", encoding="utf-8") + (repo / "skills" / "gradio" / "SKILL.md").write_text( + "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", + encoding="utf-8", + ) + _commit_all(repo, "update gradio") + + result = runner.invoke(app, ["skills", "update", "gradio", "--dest", str(dest)]) + + assert result.exit_code == 0, result.output + assert "gradio: dirty (local modifications detected)" in result.stdout + assert "local edit" in skill_file.read_text(encoding="utf-8") + + def test_update_scans_multiple_roots_by_default(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + monkeypatch.chdir(tmp_path) + + add_result = runner.invoke(app, ["skills", "add", "gradio", "--claude"]) + assert add_result.exit_code == 0, add_result.output + (repo / "skills" / "gradio" / "SKILL.md").write_text( + "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", + encoding="utf-8", + ) + _commit_all(repo, "update gradio") + + result = runner.invoke(app, ["skills", "update"]) + + assert result.exit_code == 0, result.output + assert "gradio: updated" in result.stdout + assert "v2" in (tmp_path / ".agents" / "skills" / "gradio" / "SKILL.md").read_text(encoding="utf-8") + + def test_update_for_assistant_symlink_updates_central_target( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) + monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + monkeypatch.chdir(tmp_path) + + add_result = runner.invoke(app, ["skills", "add", "gradio", "--claude"]) + assert add_result.exit_code == 0, add_result.output + link = tmp_path / ".claude" / "skills" / "gradio" + central = tmp_path / ".agents" / "skills" / "gradio" + (repo / "skills" / "gradio" / "SKILL.md").write_text( + "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", + encoding="utf-8", + ) + _commit_all(repo, "update gradio") + + result = runner.invoke(app, ["skills", "update", "--claude"]) + + assert result.exit_code == 0, result.output + assert "gradio: updated" in result.stdout + assert link.is_symlink() + assert link.resolve() == central.resolve() + assert "v2" in central.joinpath("SKILL.md").read_text(encoding="utf-8") + + def test_force_reinstall_preserves_previous_install_on_staging_failure(self, tmp_path: Path) -> None: + from huggingface_hub.cli import _skills + + repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) + skill = _skills.get_marketplace_skill("gradio", repo_url=str(repo)) + destination_root = tmp_path / "managed-skills" + install_dir = _skills.install_marketplace_skill(skill, destination_root) + original_text = install_dir.joinpath("SKILL.md").read_text(encoding="utf-8") + original_populate = _skills._populate_install_dir + + def fail_populate(*, skill, install_dir): + original_populate(skill=skill, install_dir=install_dir) + install_dir.joinpath("SKILL.md").unlink() + + with patch("huggingface_hub.cli._skills._populate_install_dir", side_effect=fail_populate): + with pytest.raises(RuntimeError): + _skills.install_marketplace_skill(skill, destination_root, force=True) + + assert install_dir.exists() + assert install_dir.joinpath("SKILL.md").read_text(encoding="utf-8") == original_text From 45db8afd8f78243d841be81325a84a115a3c92dc Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 09:34:08 +0100 Subject: [PATCH 04/18] fix in docs --- docs/source/en/package_reference/cli.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index f4a33de6a2..e74245957a 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3120,7 +3120,7 @@ $ hf skills [OPTIONS] COMMAND [ARGS]... * `add`: Download a skill and install it for an AI... * `preview`: Print the generated SKILL.md to stdout. -* `update`: Update installed skills. +* `update`: Update installed marketplace-managed skills. ### `hf skills add` @@ -3137,7 +3137,7 @@ $ hf skills add [OPTIONS] [NAME] **Arguments**: -* `[NAME]`: Hugging Face skill name. +* `[NAME]`: Marketplace skill name. **Options**: From a04a70806b8b9ac2faa2683ccf68a6ff4b740139 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 10:01:49 +0100 Subject: [PATCH 05/18] Add marketplace skill install and update support --- .../huggingface-gradio/.skill-source.json | 14 + .agents/skills/huggingface-gradio/SKILL.md | 245 +++++++ .agents/skills/huggingface-gradio/examples.md | 613 ++++++++++++++++++ pyproject.toml | 6 + src/huggingface_hub/cli/_skills.py | 6 +- src/huggingface_hub/cli/skills.py | 3 +- uv.lock | 60 ++ 7 files changed, 942 insertions(+), 5 deletions(-) create mode 100644 .agents/skills/huggingface-gradio/.skill-source.json create mode 100644 .agents/skills/huggingface-gradio/SKILL.md create mode 100644 .agents/skills/huggingface-gradio/examples.md create mode 100644 uv.lock diff --git a/.agents/skills/huggingface-gradio/.skill-source.json b/.agents/skills/huggingface-gradio/.skill-source.json new file mode 100644 index 0000000000..0aaec976d5 --- /dev/null +++ b/.agents/skills/huggingface-gradio/.skill-source.json @@ -0,0 +1,14 @@ +{ + "content_fingerprint": "sha256:8be49669ffc3035b9ae69cda3356d2e93406156a11aa36d5599c69d3cff57aeb", + "installed_at": "2026-03-20T08:19:49Z", + "installed_commit": "4cdabeec700d084152c21e0e6a7e697671ab319e", + "installed_path_oid": null, + "installed_revision": "4cdabeec700d084152c21e0e6a7e697671ab319e", + "installed_via": "marketplace", + "repo_path": "skills/huggingface-gradio", + "repo_ref": null, + "repo_url": "https://github.com/huggingface/skills", + "schema_version": 1, + "source_origin": "remote", + "source_url": null +} diff --git a/.agents/skills/huggingface-gradio/SKILL.md b/.agents/skills/huggingface-gradio/SKILL.md new file mode 100644 index 0000000000..eaa6732e99 --- /dev/null +++ b/.agents/skills/huggingface-gradio/SKILL.md @@ -0,0 +1,245 @@ +--- +name: gradio +description: Build Gradio web UIs and demos in Python. Use when creating or editing Gradio apps, components, event listeners, layouts, or chatbots. +--- + +# Gradio + +Gradio is a Python library for building interactive web UIs and ML demos. This skill covers the core API, patterns, and examples. + +## Guides + +Detailed guides on specific topics (read these when relevant): + +- [Quickstart](https://www.gradio.app/guides/quickstart) +- [The Interface Class](https://www.gradio.app/guides/the-interface-class) +- [Blocks and Event Listeners](https://www.gradio.app/guides/blocks-and-event-listeners) +- [Controlling Layout](https://www.gradio.app/guides/controlling-layout) +- [More Blocks Features](https://www.gradio.app/guides/more-blocks-features) +- [Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS) +- [Streaming Outputs](https://www.gradio.app/guides/streaming-outputs) +- [Streaming Inputs](https://www.gradio.app/guides/streaming-inputs) +- [Sharing Your App](https://www.gradio.app/guides/sharing-your-app) +- [Custom HTML Components](https://www.gradio.app/guides/custom-HTML-components) +- [Getting Started with the Python Client](https://www.gradio.app/guides/getting-started-with-the-python-client) +- [Getting Started with the JS Client](https://www.gradio.app/guides/getting-started-with-the-js-client) + +## Core Patterns + +**Interface** (high-level): wraps a function with input/output components. + +```python +import gradio as gr + +def greet(name): + return f"Hello {name}!" + +gr.Interface(fn=greet, inputs="text", outputs="text").launch() +``` + +**Blocks** (low-level): flexible layout with explicit event wiring. + +```python +import gradio as gr + +with gr.Blocks() as demo: + name = gr.Textbox(label="Name") + output = gr.Textbox(label="Greeting") + btn = gr.Button("Greet") + btn.click(fn=lambda n: f"Hello {n}!", inputs=name, outputs=output) + +demo.launch() +``` + +**ChatInterface**: high-level wrapper for chatbot UIs. + +```python +import gradio as gr + +def respond(message, history): + return f"You said: {message}" + +gr.ChatInterface(fn=respond).launch() +``` + +## Key Component Signatures + +### `Textbox(value: str | I18nData | Callable | None = None, type: Literal['text', 'password', 'email'] = "text", lines: int = 1, max_lines: int | None = None, placeholder: str | I18nData | None = None, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, autofocus: bool = False, autoscroll: bool = True, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", text_align: Literal['left', 'right'] | None = None, rtl: bool = False, buttons: list[Literal['copy'] | Button] | None = None, max_length: int | None = None, submit_btn: str | bool | None = False, stop_btn: str | bool | None = False, html_attributes: InputHTMLAttributes | None = None)` +Creates a textarea for user to enter string input or display string output.. + +### `Number(value: float | Callable | None = None, label: str | I18nData | None = None, placeholder: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None, precision: int | None = None, minimum: float | None = None, maximum: float | None = None, step: float = 1)` +Creates a numeric field for user to enter numbers as input or display numeric output.. + +### `Slider(minimum: float = 0, maximum: float = 100, value: float | Callable | None = None, step: float | None = None, precision: int | None = None, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", randomize: bool = False, buttons: list[Literal['reset']] | None = None)` +Creates a slider that ranges from {minimum} to {maximum} with a step size of {step}.. + +### `Checkbox(value: bool | Callable = False, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None)` +Creates a checkbox that can be set to `True` or `False`. + +### `Dropdown(choices: Sequence[str | int | float | tuple[str, str | int | float]] | None = None, value: str | int | float | Sequence[str | int | float] | Callable | DefaultValue | None = DefaultValue(), type: Literal['value', 'index'] = "value", multiselect: bool | None = None, allow_custom_value: bool = False, max_choices: int | None = None, filterable: bool = True, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None)` +Creates a dropdown of choices from which a single entry or multiple entries can be selected (as an input component) or displayed (as an output component).. + +### `Radio(choices: Sequence[str | int | float | tuple[str, str | int | float]] | None = None, value: str | int | float | Callable | None = None, type: Literal['value', 'index'] = "value", label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", rtl: bool = False, buttons: list[Button] | None = None)` +Creates a set of (string or numeric type) radio buttons of which only one can be selected.. + +### `Image(value: str | PIL.Image.Image | np.ndarray | Callable | None = None, format: str = "webp", height: int | str | None = None, width: int | str | None = None, image_mode: Literal['1', 'L', 'P', 'RGB', 'RGBA', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F'] | None = "RGB", sources: list[Literal['upload', 'webcam', 'clipboard']] | Literal['upload', 'webcam', 'clipboard'] | None = None, type: Literal['numpy', 'pil', 'filepath'] = "numpy", label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, buttons: list[Literal['download', 'share', 'fullscreen'] | Button] | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, streaming: bool = False, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", webcam_options: WebcamOptions | None = None, placeholder: str | None = None, watermark: WatermarkOptions | None = None)` +Creates an image component that can be used to upload images (as an input) or display images (as an output).. + +### `Audio(value: str | Path | tuple[int, np.ndarray] | Callable | None = None, sources: list[Literal['upload', 'microphone']] | Literal['upload', 'microphone'] | None = None, type: Literal['numpy', 'filepath'] = "numpy", label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, streaming: bool = False, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", format: Literal['wav', 'mp3'] | None = None, autoplay: bool = False, editable: bool = True, buttons: list[Literal['download', 'share'] | Button] | None = None, waveform_options: WaveformOptions | dict | None = None, loop: bool = False, recording: bool = False, subtitles: str | Path | list[dict[str, Any]] | None = None, playback_position: float = 0)` +Creates an audio component that can be used to upload/record audio (as an input) or display audio (as an output).. + +### `Video(value: str | Path | Callable | None = None, format: str | None = None, sources: list[Literal['upload', 'webcam']] | Literal['upload', 'webcam'] | None = None, height: int | str | None = None, width: int | str | None = None, label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", webcam_options: WebcamOptions | None = None, include_audio: bool | None = None, autoplay: bool = False, buttons: list[Literal['download', 'share'] | Button] | None = None, loop: bool = False, streaming: bool = False, watermark: WatermarkOptions | None = None, subtitles: str | Path | list[dict[str, Any]] | None = None, playback_position: float = 0)` +Creates a video component that can be used to upload/record videos (as an input) or display videos (as an output). + +### `File(value: str | list[str] | Callable | None = None, file_count: Literal['single', 'multiple', 'directory'] = "single", file_types: list[str] | None = None, type: Literal['filepath', 'binary'] = "filepath", label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, height: int | str | float | None = None, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", allow_reordering: bool = False, buttons: list[Button] | None = None)` +Creates a file component that allows uploading one or more generic files (when used as an input) or displaying generic files or URLs for download (as output). + +### `Chatbot(value: list[MessageDict | Message] | Callable | None = None, label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, autoscroll: bool = True, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", height: int | str | None = 400, resizable: bool = False, max_height: int | str | None = None, min_height: int | str | None = None, editable: Literal['user', 'all'] | None = None, latex_delimiters: list[dict[str, str | bool]] | None = None, rtl: bool = False, buttons: list[Literal['share', 'copy', 'copy_all'] | Button] | None = None, watermark: str | None = None, avatar_images: tuple[str | Path | None, str | Path | None] | None = None, sanitize_html: bool = True, render_markdown: bool = True, feedback_options: list[str] | tuple[str, ...] | None = ('Like', 'Dislike'), feedback_value: Sequence[str | None] | None = None, line_breaks: bool = True, layout: Literal['panel', 'bubble'] | None = None, placeholder: str | None = None, examples: list[ExampleMessage] | None = None, allow_file_downloads: = True, group_consecutive_messages: bool = True, allow_tags: list[str] | bool = True, reasoning_tags: list[tuple[str, str]] | None = None, like_user_message: bool = False)` +Creates a chatbot that displays user-submitted messages and responses. + +### `Button(value: str | I18nData | Callable = "Run", every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, variant: Literal['primary', 'secondary', 'stop', 'huggingface'] = "secondary", size: Literal['sm', 'md', 'lg'] = "lg", icon: str | Path | None = None, link: str | None = None, link_target: Literal['_self', '_blank', '_parent', '_top'] = "_self", visible: bool | Literal['hidden'] = True, interactive: bool = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", scale: int | None = None, min_width: int | None = None)` +Creates a button that can be assigned arbitrary .click() events. + +### `Markdown(value: str | I18nData | Callable | None = None, label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, rtl: bool = False, latex_delimiters: list[dict[str, str | bool]] | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", sanitize_html: bool = True, line_breaks: bool = False, header_links: bool = False, height: int | str | None = None, max_height: int | str | None = None, min_height: int | str | None = None, buttons: list[Literal['copy']] | None = None, container: bool = False, padding: bool = False)` +Used to render arbitrary Markdown output. + +### `HTML(value: Any | Callable | None = None, label: str | I18nData | None = None, html_template: str = "${value}", css_template: str = "", js_on_load: str | None = "element.addEventListener('click', function() { trigger('click') });", apply_default_css: bool = True, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool = False, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", min_height: int | None = None, max_height: int | None = None, container: bool = False, padding: bool = False, autoscroll: bool = False, buttons: list[Button] | None = None, props: Any)` +Creates a component with arbitrary HTML. + + +## Custom HTML Components + +If a task requires significant customization of an existing component or a component that doesn't exist in Gradio, you can create one with `gr.HTML`. It supports `html_template` (with `${}` JS expressions and `{{}}` Handlebars syntax), `css_template` for scoped styles, and `js_on_load` for interactivity — where `props.value` updates the component value and `trigger('event_name')` fires Gradio events. For reuse, subclass `gr.HTML` and define `api_info()` for API/MCP support. See the [full guide](https://www.gradio.app/guides/custom-HTML-components). + +Here's an example that shows how to create and use these kinds of components: + +```python +import gradio as gr + +class StarRating(gr.HTML): + def __init__(self, label, value=0, **kwargs): + html_template = """ +

${label} rating:

+ ${Array.from({length: 5}, (_, i) => ``).join('')} + """ + css_template = """ + img { height: 50px; display: inline-block; cursor: pointer; } + .faded { filter: grayscale(100%); opacity: 0.3; } + """ + js_on_load = """ + const imgs = element.querySelectorAll('img'); + imgs.forEach((img, index) => { + img.addEventListener('click', () => { + props.value = index + 1; + }); + }); + """ + super().__init__(value=value, label=label, html_template=html_template, css_template=css_template, js_on_load=js_on_load, **kwargs) + + def api_info(self): + return {"type": "integer", "minimum": 0, "maximum": 5} + + +with gr.Blocks() as demo: + gr.Markdown("# Restaurant Review") + food_rating = StarRating(label="Food", value=3) + service_rating = StarRating(label="Service", value=3) + ambience_rating = StarRating(label="Ambience", value=3) + average_btn = gr.Button("Calculate Average Rating") + rating_output = StarRating(label="Average", value=3) + def calculate_average(food, service, ambience): + return round((food + service + ambience) / 3) + average_btn.click( + fn=calculate_average, + inputs=[food_rating, service_rating, ambience_rating], + outputs=rating_output + ) + +demo.launch() +``` + +## Event Listeners + +All event listeners share the same signature: + +```python +component.event_name( + fn: Callable | None | Literal["decorator"] = "decorator", + inputs: Component | Sequence[Component] | set[Component] | None = None, + outputs: Component | Sequence[Component] | set[Component] | None = None, + api_name: str | None = None, + api_description: str | None | Literal[False] = None, + scroll_to_output: bool = False, + show_progress: Literal["full", "minimal", "hidden"] = "full", + show_progress_on: Component | Sequence[Component] | None = None, + queue: bool = True, + batch: bool = False, + max_batch_size: int = 4, + preprocess: bool = True, + postprocess: bool = True, + cancels: dict[str, Any] | list[dict[str, Any]] | None = None, + trigger_mode: Literal["once", "multiple", "always_last"] | None = None, + js: str | Literal[True] | None = None, + concurrency_limit: int | None | Literal["default"] = "default", + concurrency_id: str | None = None, + api_visibility: Literal["public", "private", "undocumented"] = "public", + time_limit: int | None = None, + stream_every: float = 0.5, + key: int | str | tuple[int | str, ...] | None = None, + validator: Callable | None = None, +) -> Dependency +``` + +Supported events per component: + +- **AnnotatedImage**: select +- **Audio**: stream, change, clear, play, pause, stop, pause, start_recording, pause_recording, stop_recording, upload, input +- **BarPlot**: select, double_click +- **BrowserState**: change +- **Button**: click +- **Chatbot**: change, select, like, retry, undo, example_select, option_select, clear, copy, edit +- **Checkbox**: change, input, select +- **CheckboxGroup**: change, input, select +- **ClearButton**: click +- **Code**: change, input, focus, blur +- **ColorPicker**: change, input, submit, focus, blur +- **Dataframe**: change, input, select, edit +- **Dataset**: click, select +- **DateTime**: change, submit +- **DeepLinkButton**: click +- **Dialogue**: change, input, submit +- **DownloadButton**: click +- **Dropdown**: change, input, select, focus, blur, key_up +- **DuplicateButton**: click +- **File**: change, select, clear, upload, delete, download +- **FileExplorer**: change, input, select +- **Gallery**: select, upload, change, delete, preview_close, preview_open +- **HTML**: change, input, click, double_click, submit, stop, edit, clear, play, pause, end, start_recording, pause_recording, stop_recording, focus, blur, upload, release, select, stream, like, example_select, option_select, load, key_up, apply, delete, tick, undo, retry, expand, collapse, download, copy +- **HighlightedText**: change, select +- **Image**: clear, change, stream, select, upload, input +- **ImageEditor**: clear, change, input, select, upload, apply +- **ImageSlider**: clear, change, stream, select, upload, input +- **JSON**: change +- **Label**: change, select +- **LinePlot**: select, double_click +- **LoginButton**: click +- **Markdown**: change, copy +- **Model3D**: change, upload, edit, clear +- **MultimodalTextbox**: change, input, select, submit, focus, blur, stop +- **Navbar**: change +- **Number**: change, input, submit, focus, blur +- **ParamViewer**: change, upload +- **Plot**: change +- **Radio**: select, change, input +- **ScatterPlot**: select, double_click +- **SimpleImage**: clear, change, upload +- **Slider**: change, input, release +- **State**: change +- **Textbox**: change, input, select, submit, focus, blur, stop, copy +- **Timer**: tick +- **UploadButton**: click, upload +- **Video**: change, clear, start_recording, stop_recording, stop, play, pause, end, upload, input + +## Additional Reference + +- [End-to-End Examples](examples.md) — complete working apps diff --git a/.agents/skills/huggingface-gradio/examples.md b/.agents/skills/huggingface-gradio/examples.md new file mode 100644 index 0000000000..b48c4cdc6d --- /dev/null +++ b/.agents/skills/huggingface-gradio/examples.md @@ -0,0 +1,613 @@ +# Gradio End-to-End Examples + +Complete working Gradio apps for reference. + +## Blocks Essay Simple + +```python +import gradio as gr + +def change_textbox(choice): + if choice == "short": + return gr.Textbox(lines=2, visible=True) + elif choice == "long": + return gr.Textbox(lines=8, visible=True, value="Lorem ipsum dolor sit amet") + else: + return gr.Textbox(visible=False) + +with gr.Blocks() as demo: + radio = gr.Radio( + ["short", "long", "none"], label="What kind of essay would you like to write?" + ) + text = gr.Textbox(lines=2, interactive=True, buttons=["copy"]) + radio.change(fn=change_textbox, inputs=radio, outputs=text) + +demo.launch() +``` + +## Blocks Flipper + +```python +import numpy as np +import gradio as gr + +def flip_text(x): + return x[::-1] + +def flip_image(x): + return np.fliplr(x) + +with gr.Blocks() as demo: + gr.Markdown("Flip text or image files using this demo.") + with gr.Tab("Flip Text"): + text_input = gr.Textbox() + text_output = gr.Textbox() + text_button = gr.Button("Flip") + with gr.Tab("Flip Image"): + with gr.Row(): + image_input = gr.Image() + image_output = gr.Image() + image_button = gr.Button("Flip") + + with gr.Accordion("Open for More!", open=False): + gr.Markdown("Look at me...") + temp_slider = gr.Slider( + 0, 1, + value=0.1, + step=0.1, + interactive=True, + label="Slide me", + ) + + text_button.click(flip_text, inputs=text_input, outputs=text_output) + image_button.click(flip_image, inputs=image_input, outputs=image_output) + +demo.launch() +``` + +## Blocks Form + +```python +import gradio as gr + +with gr.Blocks() as demo: + name_box = gr.Textbox(label="Name") + age_box = gr.Number(label="Age", minimum=0, maximum=100) + symptoms_box = gr.CheckboxGroup(["Cough", "Fever", "Runny Nose"]) + submit_btn = gr.Button("Submit") + + with gr.Column(visible=False) as output_col: + diagnosis_box = gr.Textbox(label="Diagnosis") + patient_summary_box = gr.Textbox(label="Patient Summary") + + def submit(name, age, symptoms): + return { + submit_btn: gr.Button(visible=False), + output_col: gr.Column(visible=True), + diagnosis_box: "covid" if "Cough" in symptoms else "flu", + patient_summary_box: f"{name}, {age} y/o", + } + + submit_btn.click( + submit, + [name_box, age_box, symptoms_box], + [submit_btn, diagnosis_box, patient_summary_box, output_col], + ) + +demo.launch() +``` + +## Blocks Hello + +```python +import gradio as gr + +def welcome(name): + return f"Welcome to Gradio, {name}!" + +with gr.Blocks() as demo: + gr.Markdown( + """ + # Hello World! + Start typing below to see the output. + """) + inp = gr.Textbox(placeholder="What is your name?") + out = gr.Textbox() + inp.change(welcome, inp, out) + +demo.launch() +``` + +## Blocks Layout + +```python +import gradio as gr + +demo = gr.Blocks() + +with demo: + with gr.Row(): + gr.Image(interactive=True, scale=2) + gr.Image() + with gr.Row(): + gr.Textbox(label="Text") + gr.Number(label="Count", scale=2) + gr.Radio(choices=["One", "Two"]) + with gr.Row(): + gr.Button("500", scale=0, min_width=500) + gr.Button("A", scale=0) + gr.Button("grow") + with gr.Row(): + gr.Textbox() + gr.Textbox() + gr.Button() + with gr.Row(): + with gr.Row(): + with gr.Column(): + gr.Textbox(label="Text") + gr.Number(label="Count") + gr.Radio(choices=["One", "Two"]) + gr.Image() + with gr.Column(): + gr.Image(interactive=True) + gr.Image() + gr.Image() + gr.Textbox(label="Text") + gr.Number(label="Count") + gr.Radio(choices=["One", "Two"]) + +demo.launch() +``` + +## Calculator + +```python +import gradio as gr + +def calculator(num1, operation, num2): + if operation == "add": + return num1 + num2 + elif operation == "subtract": + return num1 - num2 + elif operation == "multiply": + return num1 * num2 + elif operation == "divide": + if num2 == 0: + raise gr.Error("Cannot divide by zero!") + return num1 / num2 + +demo = gr.Interface( + calculator, + [ + "number", + gr.Radio(["add", "subtract", "multiply", "divide"]), + "number" + ], + "number", + examples=[ + [45, "add", 3], + [3.14, "divide", 2], + [144, "multiply", 2.5], + [0, "subtract", 1.2], + ], + title="Toy Calculator", + description="Here's a sample toy calculator.", + api_name="predict" +) + +demo.launch() +``` + +## Chatbot Simple + +```python +import gradio as gr +import random +import time + +with gr.Blocks() as demo: + chatbot = gr.Chatbot() + msg = gr.Textbox() + clear = gr.ClearButton([msg, chatbot]) + + def respond(message, chat_history): + bot_message = random.choice(["How are you?", "Today is a great day", "I'm very hungry"]) + chat_history.append({"role": "user", "content": message}) + chat_history.append({"role": "assistant", "content": bot_message}) + time.sleep(2) + return "", chat_history + + msg.submit(respond, [msg, chatbot], [msg, chatbot]) + +demo.launch() +``` + +## Chatbot Streaming + +```python +import gradio as gr +import random +import time + +with gr.Blocks() as demo: + chatbot = gr.Chatbot() + msg = gr.Textbox() + clear = gr.Button("Clear") + + def user(user_message, history: list): + return "", history + [{"role": "user", "content": user_message}] + + def bot(history: list): + bot_message = random.choice(["How are you?", "I love you", "I'm very hungry"]) + history.append({"role": "assistant", "content": ""}) + for character in bot_message: + history[-1]['content'] += character + time.sleep(0.05) + yield history + + msg.submit(user, [msg, chatbot], [msg, chatbot], queue=False).then( + bot, chatbot, chatbot + ) + clear.click(lambda: None, None, chatbot, queue=False) + +demo.launch() +``` + +## Custom Css + +```python +import gradio as gr + +with gr.Blocks() as demo: + with gr.Column(elem_classes="cool-col"): + gr.Markdown("### Gradio Demo with Custom CSS", elem_classes="darktest") + gr.Markdown( + elem_classes="markdown", + value="Resize the browser window to see the CSS media query in action.", + ) + +if __name__ == "__main__": + demo.launch(css_paths=["demo/custom_css/custom_css.css"]) +``` + +## Fake Diffusion + +```python +import gradio as gr +import numpy as np +import time + +def fake_diffusion(steps): + rng = np.random.default_rng() + for i in range(steps): + time.sleep(1) + image = rng.random(size=(600, 600, 3)) + yield image + image = np.ones((1000,1000,3), np.uint8) + image[:] = [255, 124, 0] + yield image + +demo = gr.Interface(fake_diffusion, + inputs=gr.Slider(1, 10, 3, step=1), + outputs="image", + api_name="predict") + +demo.launch() +``` + +## Hello World + +```python +import gradio as gr + + +def greet(name): + return "Hello " + name + "!" + + +demo = gr.Interface(fn=greet, inputs="textbox", outputs="textbox", api_name="predict") + +demo.launch() +``` + +## Image Editor + +```python +import gradio as gr +import time + + +def sleep(im): + time.sleep(5) + return [im["background"], im["layers"][0], im["layers"][1], im["composite"]] + + +def predict(im): + return im["composite"] + + +with gr.Blocks() as demo: + with gr.Row(): + im = gr.ImageEditor( + type="numpy", + ) + im_preview = gr.Image() + n_upload = gr.Number(0, label="Number of upload events", step=1) + n_change = gr.Number(0, label="Number of change events", step=1) + n_input = gr.Number(0, label="Number of input events", step=1) + + im.upload(lambda x: x + 1, outputs=n_upload, inputs=n_upload) + im.change(lambda x: x + 1, outputs=n_change, inputs=n_change) + im.input(lambda x: x + 1, outputs=n_input, inputs=n_input) + im.change(predict, outputs=im_preview, inputs=im, show_progress="hidden") + +demo.launch() +``` + +## On Listener Decorator + +```python +import gradio as gr + +with gr.Blocks() as demo: + name = gr.Textbox(label="Name") + output = gr.Textbox(label="Output Box") + greet_btn = gr.Button("Greet") + + @gr.on(triggers=[name.submit, greet_btn.click], inputs=name, outputs=output) + def greet(name): + return "Hello " + name + "!" + +demo.launch() +``` + +## Render Merge + +```python +import gradio as gr +import time + +with gr.Blocks() as demo: + text_count = gr.Slider(1, 5, value=1, step=1, label="Textbox Count") + + @gr.render(inputs=text_count) + def render_count(count): + boxes = [] + for i in range(count): + box = gr.Textbox(label=f"Box {i}") + boxes.append(box) + + def merge(*args): + time.sleep(0.2) # simulate a delay + return " ".join(args) + + merge_btn.click(merge, boxes, output) + + def clear(): + time.sleep(0.2) # simulate a delay + return [" "] * count + + clear_btn.click(clear, None, boxes) + + def countup(): + time.sleep(0.2) # simulate a delay + return list(range(count)) + + count_btn.click(countup, None, boxes, queue=False) + + with gr.Row(): + merge_btn = gr.Button("Merge") + clear_btn = gr.Button("Clear") + count_btn = gr.Button("Count") + + output = gr.Textbox() + +demo.launch() +``` + +## Reverse Audio 2 + +```python +import gradio as gr +import numpy as np + +def reverse_audio(audio): + sr, data = audio + return (sr, np.flipud(data)) + +demo = gr.Interface(fn=reverse_audio, + inputs="microphone", + outputs="audio", api_name="predict") + +demo.launch() +``` + +## Sepia Filter + +```python +import numpy as np +import gradio as gr + +def sepia(input_img): + sepia_filter = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131] + ]) + sepia_img = input_img.dot(sepia_filter.T) + sepia_img /= sepia_img.max() + return sepia_img + +demo = gr.Interface(sepia, gr.Image(), "image", api_name="predict") +demo.launch() +``` + +## Sort Records + +```python +import gradio as gr + +def sort_records(records): + return records.sort("Quantity") + +demo = gr.Interface( + sort_records, + gr.Dataframe( + headers=["Item", "Quantity"], + datatype=["str", "number"], + row_count=3, + column_count=2, + column_limits=(2, 2), + type="polars" + ), + "dataframe", + description="Sort by Quantity" +) + +demo.launch() +``` + +## Streaming Simple + +```python +import gradio as gr + +with gr.Blocks() as demo: + with gr.Row(): + with gr.Column(): + input_img = gr.Image(label="Input", sources="webcam") + with gr.Column(): + output_img = gr.Image(label="Output") + input_img.stream(lambda s: s, input_img, output_img, time_limit=15, stream_every=0.1, concurrency_limit=30) + +if __name__ == "__main__": + + demo.launch() +``` + +## Tabbed Interface Lite + +```python +import gradio as gr + +hello_world = gr.Interface(lambda name: "Hello " + name, "text", "text", api_name="predict") +bye_world = gr.Interface(lambda name: "Bye " + name, "text", "text", api_name="predict") +chat = gr.ChatInterface(lambda *args: "Hello " + args[0], api_name="chat") + +demo = gr.TabbedInterface([hello_world, bye_world, chat], ["Hello World", "Bye World", "Chat"]) + +demo.launch() +``` + +## Tax Calculator + +```python +import gradio as gr + +def tax_calculator(income, marital_status, assets): + tax_brackets = [(10, 0), (25, 8), (60, 12), (120, 20), (250, 30)] + total_deductible = sum(cost for cost, deductible in zip(assets["Cost"], assets["Deductible"]) if deductible) + taxable_income = income - total_deductible + + total_tax = 0 + for bracket, rate in tax_brackets: + if taxable_income > bracket: + total_tax += (taxable_income - bracket) * rate / 100 + + if marital_status == "Married": + total_tax *= 0.75 + elif marital_status == "Divorced": + total_tax *= 0.8 + + return round(total_tax) + +demo = gr.Interface( + tax_calculator, + [ + "number", + gr.Radio(["Single", "Married", "Divorced"]), + gr.Dataframe( + headers=["Item", "Cost", "Deductible"], + datatype=["str", "number", "bool"], + label="Assets Purchased this Year", + ), + ], + gr.Number(label="Tax due"), + examples=[ + [10000, "Married", [["Suit", 5000, True], ["Laptop (for work)", 800, False], ["Car", 1800, True]]], + [80000, "Single", [["Suit", 800, True], ["Watch", 1800, True], ["Food", 800, True]]], + ], + live=True, + api_name="predict" +) + +demo.launch() +``` + +## Timer Simple + +```python +import gradio as gr +import random +import time + +with gr.Blocks() as demo: + timer = gr.Timer(1) + timestamp = gr.Number(label="Time") + timer.tick(lambda: round(time.time()), outputs=timestamp, api_name="timestamp") + + number = gr.Number(lambda: random.randint(1, 10), every=timer, label="Random Number") + with gr.Row(): + gr.Button("Start").click(lambda: gr.Timer(active=True), None, timer) + gr.Button("Stop").click(lambda: gr.Timer(active=False), None, timer) + gr.Button("Go Fast").click(lambda: 0.2, None, timer) + +if __name__ == "__main__": + demo.launch() +``` + +## Variable Outputs + +```python +import gradio as gr + +max_textboxes = 10 + +def variable_outputs(k): + k = int(k) + return [gr.Textbox(visible=True)]*k + [gr.Textbox(visible=False)]*(max_textboxes-k) + +with gr.Blocks() as demo: + s = gr.Slider(1, max_textboxes, value=max_textboxes, step=1, label="How many textboxes to show:") + textboxes = [] + for i in range(max_textboxes): + t = gr.Textbox(f"Textbox {i}") + textboxes.append(t) + + s.change(variable_outputs, s, textboxes) + +if __name__ == "__main__": + demo.launch() +``` + +## Video Identity + +```python +import gradio as gr +from gradio.media import get_video + +def video_identity(video): + return video + +# get_video() returns file paths to sample media included with Gradio +demo = gr.Interface(video_identity, + gr.Video(), + "playable_video", + examples=[ + get_video("world.mp4") + ], + cache_examples=True, + api_name="predict",) + +demo.launch() +``` diff --git a/pyproject.toml b/pyproject.toml index 9fd8f55e1e..cca384acd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,3 +82,9 @@ in_place = true spaces_before_inline_comment = 2 # Match Python PEP 8 spaces_indent_inline_array = 4 # Match Python PEP 8 trailing_comma_inline_array = true + +[dependency-groups] +dev = [ + "ruff>=0.15.7", + "ty>=0.0.24", +] diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index d6c1c97bb7..79c42bf833 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -10,7 +10,7 @@ import tarfile import tempfile from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any, Literal from urllib.parse import urlparse @@ -137,13 +137,11 @@ def install_marketplace_skill(skill: MarketplaceSkill, destination_root: Path, f tmp_dir = Path(tmp_dir_str) staged_dir = tmp_dir / install_dir.name _populate_install_dir(skill=skill, install_dir=staged_dir) - _validate_installed_skill_dir(staged_dir) _atomic_replace_directory(existing_dir=install_dir, staged_dir=staged_dir) return install_dir try: _populate_install_dir(skill=skill, install_dir=install_dir) - _validate_installed_skill_dir(install_dir) except Exception: if install_dir.exists(): shutil.rmtree(install_dir) @@ -678,4 +676,4 @@ def _git_ls_remote(repo_url: str, repo_ref: str | None) -> str: def _iso_utc_now() -> str: - return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index 745dff7b59..27087916b4 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -37,6 +37,7 @@ hf skills add --claude --force """ +import os import shutil from pathlib import Path from typing import Annotated, Optional @@ -294,7 +295,7 @@ def _create_symlink(agent_skills_dir: Path, skill_name: str, central_skill_path: link_path = agent_skills_dir / skill_name _remove_existing(link_path, force) - link_path.symlink_to(central_skill_path.relative_to(agent_skills_dir, walk_up=True)) + link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir)) return link_path diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..8df3cd2c40 --- /dev/null +++ b/uv.lock @@ -0,0 +1,60 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[manifest] + +[manifest.dependency-groups] +dev = [ + { name = "ruff", specifier = ">=0.15.7" }, + { name = "ty", specifier = ">=0.0.24" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] From 7194f4b5250d201d90d9d44cc9886a76ec8e892e Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 10:11:00 +0100 Subject: [PATCH 06/18] Add marketplace skill install and update support --- src/huggingface_hub/cli/_skills.py | 6 ++---- src/huggingface_hub/cli/skills.py | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index d6c1c97bb7..79c42bf833 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -10,7 +10,7 @@ import tarfile import tempfile from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any, Literal from urllib.parse import urlparse @@ -137,13 +137,11 @@ def install_marketplace_skill(skill: MarketplaceSkill, destination_root: Path, f tmp_dir = Path(tmp_dir_str) staged_dir = tmp_dir / install_dir.name _populate_install_dir(skill=skill, install_dir=staged_dir) - _validate_installed_skill_dir(staged_dir) _atomic_replace_directory(existing_dir=install_dir, staged_dir=staged_dir) return install_dir try: _populate_install_dir(skill=skill, install_dir=install_dir) - _validate_installed_skill_dir(install_dir) except Exception: if install_dir.exists(): shutil.rmtree(install_dir) @@ -678,4 +676,4 @@ def _git_ls_remote(repo_url: str, repo_ref: str | None) -> str: def _iso_utc_now() -> str: - return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index 745dff7b59..27087916b4 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -37,6 +37,7 @@ hf skills add --claude --force """ +import os import shutil from pathlib import Path from typing import Annotated, Optional @@ -294,7 +295,7 @@ def _create_symlink(agent_skills_dir: Path, skill_name: str, central_skill_path: link_path = agent_skills_dir / skill_name _remove_existing(link_path, force) - link_path.symlink_to(central_skill_path.relative_to(agent_skills_dir, walk_up=True)) + link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir)) return link_path From cb375ce5c56a375d3661ab11f98995dfb16d9112 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 10:14:14 +0100 Subject: [PATCH 07/18] drop skill excess --- .../huggingface-gradio/.skill-source.json | 14 - .agents/skills/huggingface-gradio/SKILL.md | 245 ------- .agents/skills/huggingface-gradio/examples.md | 613 ------------------ 3 files changed, 872 deletions(-) delete mode 100644 .agents/skills/huggingface-gradio/.skill-source.json delete mode 100644 .agents/skills/huggingface-gradio/SKILL.md delete mode 100644 .agents/skills/huggingface-gradio/examples.md diff --git a/.agents/skills/huggingface-gradio/.skill-source.json b/.agents/skills/huggingface-gradio/.skill-source.json deleted file mode 100644 index 0aaec976d5..0000000000 --- a/.agents/skills/huggingface-gradio/.skill-source.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "content_fingerprint": "sha256:8be49669ffc3035b9ae69cda3356d2e93406156a11aa36d5599c69d3cff57aeb", - "installed_at": "2026-03-20T08:19:49Z", - "installed_commit": "4cdabeec700d084152c21e0e6a7e697671ab319e", - "installed_path_oid": null, - "installed_revision": "4cdabeec700d084152c21e0e6a7e697671ab319e", - "installed_via": "marketplace", - "repo_path": "skills/huggingface-gradio", - "repo_ref": null, - "repo_url": "https://github.com/huggingface/skills", - "schema_version": 1, - "source_origin": "remote", - "source_url": null -} diff --git a/.agents/skills/huggingface-gradio/SKILL.md b/.agents/skills/huggingface-gradio/SKILL.md deleted file mode 100644 index eaa6732e99..0000000000 --- a/.agents/skills/huggingface-gradio/SKILL.md +++ /dev/null @@ -1,245 +0,0 @@ ---- -name: gradio -description: Build Gradio web UIs and demos in Python. Use when creating or editing Gradio apps, components, event listeners, layouts, or chatbots. ---- - -# Gradio - -Gradio is a Python library for building interactive web UIs and ML demos. This skill covers the core API, patterns, and examples. - -## Guides - -Detailed guides on specific topics (read these when relevant): - -- [Quickstart](https://www.gradio.app/guides/quickstart) -- [The Interface Class](https://www.gradio.app/guides/the-interface-class) -- [Blocks and Event Listeners](https://www.gradio.app/guides/blocks-and-event-listeners) -- [Controlling Layout](https://www.gradio.app/guides/controlling-layout) -- [More Blocks Features](https://www.gradio.app/guides/more-blocks-features) -- [Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS) -- [Streaming Outputs](https://www.gradio.app/guides/streaming-outputs) -- [Streaming Inputs](https://www.gradio.app/guides/streaming-inputs) -- [Sharing Your App](https://www.gradio.app/guides/sharing-your-app) -- [Custom HTML Components](https://www.gradio.app/guides/custom-HTML-components) -- [Getting Started with the Python Client](https://www.gradio.app/guides/getting-started-with-the-python-client) -- [Getting Started with the JS Client](https://www.gradio.app/guides/getting-started-with-the-js-client) - -## Core Patterns - -**Interface** (high-level): wraps a function with input/output components. - -```python -import gradio as gr - -def greet(name): - return f"Hello {name}!" - -gr.Interface(fn=greet, inputs="text", outputs="text").launch() -``` - -**Blocks** (low-level): flexible layout with explicit event wiring. - -```python -import gradio as gr - -with gr.Blocks() as demo: - name = gr.Textbox(label="Name") - output = gr.Textbox(label="Greeting") - btn = gr.Button("Greet") - btn.click(fn=lambda n: f"Hello {n}!", inputs=name, outputs=output) - -demo.launch() -``` - -**ChatInterface**: high-level wrapper for chatbot UIs. - -```python -import gradio as gr - -def respond(message, history): - return f"You said: {message}" - -gr.ChatInterface(fn=respond).launch() -``` - -## Key Component Signatures - -### `Textbox(value: str | I18nData | Callable | None = None, type: Literal['text', 'password', 'email'] = "text", lines: int = 1, max_lines: int | None = None, placeholder: str | I18nData | None = None, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, autofocus: bool = False, autoscroll: bool = True, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", text_align: Literal['left', 'right'] | None = None, rtl: bool = False, buttons: list[Literal['copy'] | Button] | None = None, max_length: int | None = None, submit_btn: str | bool | None = False, stop_btn: str | bool | None = False, html_attributes: InputHTMLAttributes | None = None)` -Creates a textarea for user to enter string input or display string output.. - -### `Number(value: float | Callable | None = None, label: str | I18nData | None = None, placeholder: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None, precision: int | None = None, minimum: float | None = None, maximum: float | None = None, step: float = 1)` -Creates a numeric field for user to enter numbers as input or display numeric output.. - -### `Slider(minimum: float = 0, maximum: float = 100, value: float | Callable | None = None, step: float | None = None, precision: int | None = None, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", randomize: bool = False, buttons: list[Literal['reset']] | None = None)` -Creates a slider that ranges from {minimum} to {maximum} with a step size of {step}.. - -### `Checkbox(value: bool | Callable = False, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None)` -Creates a checkbox that can be set to `True` or `False`. - -### `Dropdown(choices: Sequence[str | int | float | tuple[str, str | int | float]] | None = None, value: str | int | float | Sequence[str | int | float] | Callable | DefaultValue | None = DefaultValue(), type: Literal['value', 'index'] = "value", multiselect: bool | None = None, allow_custom_value: bool = False, max_choices: int | None = None, filterable: bool = True, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None)` -Creates a dropdown of choices from which a single entry or multiple entries can be selected (as an input component) or displayed (as an output component).. - -### `Radio(choices: Sequence[str | int | float | tuple[str, str | int | float]] | None = None, value: str | int | float | Callable | None = None, type: Literal['value', 'index'] = "value", label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", rtl: bool = False, buttons: list[Button] | None = None)` -Creates a set of (string or numeric type) radio buttons of which only one can be selected.. - -### `Image(value: str | PIL.Image.Image | np.ndarray | Callable | None = None, format: str = "webp", height: int | str | None = None, width: int | str | None = None, image_mode: Literal['1', 'L', 'P', 'RGB', 'RGBA', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F'] | None = "RGB", sources: list[Literal['upload', 'webcam', 'clipboard']] | Literal['upload', 'webcam', 'clipboard'] | None = None, type: Literal['numpy', 'pil', 'filepath'] = "numpy", label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, buttons: list[Literal['download', 'share', 'fullscreen'] | Button] | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, streaming: bool = False, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", webcam_options: WebcamOptions | None = None, placeholder: str | None = None, watermark: WatermarkOptions | None = None)` -Creates an image component that can be used to upload images (as an input) or display images (as an output).. - -### `Audio(value: str | Path | tuple[int, np.ndarray] | Callable | None = None, sources: list[Literal['upload', 'microphone']] | Literal['upload', 'microphone'] | None = None, type: Literal['numpy', 'filepath'] = "numpy", label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, streaming: bool = False, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", format: Literal['wav', 'mp3'] | None = None, autoplay: bool = False, editable: bool = True, buttons: list[Literal['download', 'share'] | Button] | None = None, waveform_options: WaveformOptions | dict | None = None, loop: bool = False, recording: bool = False, subtitles: str | Path | list[dict[str, Any]] | None = None, playback_position: float = 0)` -Creates an audio component that can be used to upload/record audio (as an input) or display audio (as an output).. - -### `Video(value: str | Path | Callable | None = None, format: str | None = None, sources: list[Literal['upload', 'webcam']] | Literal['upload', 'webcam'] | None = None, height: int | str | None = None, width: int | str | None = None, label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", webcam_options: WebcamOptions | None = None, include_audio: bool | None = None, autoplay: bool = False, buttons: list[Literal['download', 'share'] | Button] | None = None, loop: bool = False, streaming: bool = False, watermark: WatermarkOptions | None = None, subtitles: str | Path | list[dict[str, Any]] | None = None, playback_position: float = 0)` -Creates a video component that can be used to upload/record videos (as an input) or display videos (as an output). - -### `File(value: str | list[str] | Callable | None = None, file_count: Literal['single', 'multiple', 'directory'] = "single", file_types: list[str] | None = None, type: Literal['filepath', 'binary'] = "filepath", label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, height: int | str | float | None = None, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", allow_reordering: bool = False, buttons: list[Button] | None = None)` -Creates a file component that allows uploading one or more generic files (when used as an input) or displaying generic files or URLs for download (as output). - -### `Chatbot(value: list[MessageDict | Message] | Callable | None = None, label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, autoscroll: bool = True, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", height: int | str | None = 400, resizable: bool = False, max_height: int | str | None = None, min_height: int | str | None = None, editable: Literal['user', 'all'] | None = None, latex_delimiters: list[dict[str, str | bool]] | None = None, rtl: bool = False, buttons: list[Literal['share', 'copy', 'copy_all'] | Button] | None = None, watermark: str | None = None, avatar_images: tuple[str | Path | None, str | Path | None] | None = None, sanitize_html: bool = True, render_markdown: bool = True, feedback_options: list[str] | tuple[str, ...] | None = ('Like', 'Dislike'), feedback_value: Sequence[str | None] | None = None, line_breaks: bool = True, layout: Literal['panel', 'bubble'] | None = None, placeholder: str | None = None, examples: list[ExampleMessage] | None = None, allow_file_downloads: = True, group_consecutive_messages: bool = True, allow_tags: list[str] | bool = True, reasoning_tags: list[tuple[str, str]] | None = None, like_user_message: bool = False)` -Creates a chatbot that displays user-submitted messages and responses. - -### `Button(value: str | I18nData | Callable = "Run", every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, variant: Literal['primary', 'secondary', 'stop', 'huggingface'] = "secondary", size: Literal['sm', 'md', 'lg'] = "lg", icon: str | Path | None = None, link: str | None = None, link_target: Literal['_self', '_blank', '_parent', '_top'] = "_self", visible: bool | Literal['hidden'] = True, interactive: bool = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", scale: int | None = None, min_width: int | None = None)` -Creates a button that can be assigned arbitrary .click() events. - -### `Markdown(value: str | I18nData | Callable | None = None, label: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, rtl: bool = False, latex_delimiters: list[dict[str, str | bool]] | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", sanitize_html: bool = True, line_breaks: bool = False, header_links: bool = False, height: int | str | None = None, max_height: int | str | None = None, min_height: int | str | None = None, buttons: list[Literal['copy']] | None = None, container: bool = False, padding: bool = False)` -Used to render arbitrary Markdown output. - -### `HTML(value: Any | Callable | None = None, label: str | I18nData | None = None, html_template: str = "${value}", css_template: str = "", js_on_load: str | None = "element.addEventListener('click', function() { trigger('click') });", apply_default_css: bool = True, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool = False, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", min_height: int | None = None, max_height: int | None = None, container: bool = False, padding: bool = False, autoscroll: bool = False, buttons: list[Button] | None = None, props: Any)` -Creates a component with arbitrary HTML. - - -## Custom HTML Components - -If a task requires significant customization of an existing component or a component that doesn't exist in Gradio, you can create one with `gr.HTML`. It supports `html_template` (with `${}` JS expressions and `{{}}` Handlebars syntax), `css_template` for scoped styles, and `js_on_load` for interactivity — where `props.value` updates the component value and `trigger('event_name')` fires Gradio events. For reuse, subclass `gr.HTML` and define `api_info()` for API/MCP support. See the [full guide](https://www.gradio.app/guides/custom-HTML-components). - -Here's an example that shows how to create and use these kinds of components: - -```python -import gradio as gr - -class StarRating(gr.HTML): - def __init__(self, label, value=0, **kwargs): - html_template = """ -

${label} rating:

- ${Array.from({length: 5}, (_, i) => ``).join('')} - """ - css_template = """ - img { height: 50px; display: inline-block; cursor: pointer; } - .faded { filter: grayscale(100%); opacity: 0.3; } - """ - js_on_load = """ - const imgs = element.querySelectorAll('img'); - imgs.forEach((img, index) => { - img.addEventListener('click', () => { - props.value = index + 1; - }); - }); - """ - super().__init__(value=value, label=label, html_template=html_template, css_template=css_template, js_on_load=js_on_load, **kwargs) - - def api_info(self): - return {"type": "integer", "minimum": 0, "maximum": 5} - - -with gr.Blocks() as demo: - gr.Markdown("# Restaurant Review") - food_rating = StarRating(label="Food", value=3) - service_rating = StarRating(label="Service", value=3) - ambience_rating = StarRating(label="Ambience", value=3) - average_btn = gr.Button("Calculate Average Rating") - rating_output = StarRating(label="Average", value=3) - def calculate_average(food, service, ambience): - return round((food + service + ambience) / 3) - average_btn.click( - fn=calculate_average, - inputs=[food_rating, service_rating, ambience_rating], - outputs=rating_output - ) - -demo.launch() -``` - -## Event Listeners - -All event listeners share the same signature: - -```python -component.event_name( - fn: Callable | None | Literal["decorator"] = "decorator", - inputs: Component | Sequence[Component] | set[Component] | None = None, - outputs: Component | Sequence[Component] | set[Component] | None = None, - api_name: str | None = None, - api_description: str | None | Literal[False] = None, - scroll_to_output: bool = False, - show_progress: Literal["full", "minimal", "hidden"] = "full", - show_progress_on: Component | Sequence[Component] | None = None, - queue: bool = True, - batch: bool = False, - max_batch_size: int = 4, - preprocess: bool = True, - postprocess: bool = True, - cancels: dict[str, Any] | list[dict[str, Any]] | None = None, - trigger_mode: Literal["once", "multiple", "always_last"] | None = None, - js: str | Literal[True] | None = None, - concurrency_limit: int | None | Literal["default"] = "default", - concurrency_id: str | None = None, - api_visibility: Literal["public", "private", "undocumented"] = "public", - time_limit: int | None = None, - stream_every: float = 0.5, - key: int | str | tuple[int | str, ...] | None = None, - validator: Callable | None = None, -) -> Dependency -``` - -Supported events per component: - -- **AnnotatedImage**: select -- **Audio**: stream, change, clear, play, pause, stop, pause, start_recording, pause_recording, stop_recording, upload, input -- **BarPlot**: select, double_click -- **BrowserState**: change -- **Button**: click -- **Chatbot**: change, select, like, retry, undo, example_select, option_select, clear, copy, edit -- **Checkbox**: change, input, select -- **CheckboxGroup**: change, input, select -- **ClearButton**: click -- **Code**: change, input, focus, blur -- **ColorPicker**: change, input, submit, focus, blur -- **Dataframe**: change, input, select, edit -- **Dataset**: click, select -- **DateTime**: change, submit -- **DeepLinkButton**: click -- **Dialogue**: change, input, submit -- **DownloadButton**: click -- **Dropdown**: change, input, select, focus, blur, key_up -- **DuplicateButton**: click -- **File**: change, select, clear, upload, delete, download -- **FileExplorer**: change, input, select -- **Gallery**: select, upload, change, delete, preview_close, preview_open -- **HTML**: change, input, click, double_click, submit, stop, edit, clear, play, pause, end, start_recording, pause_recording, stop_recording, focus, blur, upload, release, select, stream, like, example_select, option_select, load, key_up, apply, delete, tick, undo, retry, expand, collapse, download, copy -- **HighlightedText**: change, select -- **Image**: clear, change, stream, select, upload, input -- **ImageEditor**: clear, change, input, select, upload, apply -- **ImageSlider**: clear, change, stream, select, upload, input -- **JSON**: change -- **Label**: change, select -- **LinePlot**: select, double_click -- **LoginButton**: click -- **Markdown**: change, copy -- **Model3D**: change, upload, edit, clear -- **MultimodalTextbox**: change, input, select, submit, focus, blur, stop -- **Navbar**: change -- **Number**: change, input, submit, focus, blur -- **ParamViewer**: change, upload -- **Plot**: change -- **Radio**: select, change, input -- **ScatterPlot**: select, double_click -- **SimpleImage**: clear, change, upload -- **Slider**: change, input, release -- **State**: change -- **Textbox**: change, input, select, submit, focus, blur, stop, copy -- **Timer**: tick -- **UploadButton**: click, upload -- **Video**: change, clear, start_recording, stop_recording, stop, play, pause, end, upload, input - -## Additional Reference - -- [End-to-End Examples](examples.md) — complete working apps diff --git a/.agents/skills/huggingface-gradio/examples.md b/.agents/skills/huggingface-gradio/examples.md deleted file mode 100644 index b48c4cdc6d..0000000000 --- a/.agents/skills/huggingface-gradio/examples.md +++ /dev/null @@ -1,613 +0,0 @@ -# Gradio End-to-End Examples - -Complete working Gradio apps for reference. - -## Blocks Essay Simple - -```python -import gradio as gr - -def change_textbox(choice): - if choice == "short": - return gr.Textbox(lines=2, visible=True) - elif choice == "long": - return gr.Textbox(lines=8, visible=True, value="Lorem ipsum dolor sit amet") - else: - return gr.Textbox(visible=False) - -with gr.Blocks() as demo: - radio = gr.Radio( - ["short", "long", "none"], label="What kind of essay would you like to write?" - ) - text = gr.Textbox(lines=2, interactive=True, buttons=["copy"]) - radio.change(fn=change_textbox, inputs=radio, outputs=text) - -demo.launch() -``` - -## Blocks Flipper - -```python -import numpy as np -import gradio as gr - -def flip_text(x): - return x[::-1] - -def flip_image(x): - return np.fliplr(x) - -with gr.Blocks() as demo: - gr.Markdown("Flip text or image files using this demo.") - with gr.Tab("Flip Text"): - text_input = gr.Textbox() - text_output = gr.Textbox() - text_button = gr.Button("Flip") - with gr.Tab("Flip Image"): - with gr.Row(): - image_input = gr.Image() - image_output = gr.Image() - image_button = gr.Button("Flip") - - with gr.Accordion("Open for More!", open=False): - gr.Markdown("Look at me...") - temp_slider = gr.Slider( - 0, 1, - value=0.1, - step=0.1, - interactive=True, - label="Slide me", - ) - - text_button.click(flip_text, inputs=text_input, outputs=text_output) - image_button.click(flip_image, inputs=image_input, outputs=image_output) - -demo.launch() -``` - -## Blocks Form - -```python -import gradio as gr - -with gr.Blocks() as demo: - name_box = gr.Textbox(label="Name") - age_box = gr.Number(label="Age", minimum=0, maximum=100) - symptoms_box = gr.CheckboxGroup(["Cough", "Fever", "Runny Nose"]) - submit_btn = gr.Button("Submit") - - with gr.Column(visible=False) as output_col: - diagnosis_box = gr.Textbox(label="Diagnosis") - patient_summary_box = gr.Textbox(label="Patient Summary") - - def submit(name, age, symptoms): - return { - submit_btn: gr.Button(visible=False), - output_col: gr.Column(visible=True), - diagnosis_box: "covid" if "Cough" in symptoms else "flu", - patient_summary_box: f"{name}, {age} y/o", - } - - submit_btn.click( - submit, - [name_box, age_box, symptoms_box], - [submit_btn, diagnosis_box, patient_summary_box, output_col], - ) - -demo.launch() -``` - -## Blocks Hello - -```python -import gradio as gr - -def welcome(name): - return f"Welcome to Gradio, {name}!" - -with gr.Blocks() as demo: - gr.Markdown( - """ - # Hello World! - Start typing below to see the output. - """) - inp = gr.Textbox(placeholder="What is your name?") - out = gr.Textbox() - inp.change(welcome, inp, out) - -demo.launch() -``` - -## Blocks Layout - -```python -import gradio as gr - -demo = gr.Blocks() - -with demo: - with gr.Row(): - gr.Image(interactive=True, scale=2) - gr.Image() - with gr.Row(): - gr.Textbox(label="Text") - gr.Number(label="Count", scale=2) - gr.Radio(choices=["One", "Two"]) - with gr.Row(): - gr.Button("500", scale=0, min_width=500) - gr.Button("A", scale=0) - gr.Button("grow") - with gr.Row(): - gr.Textbox() - gr.Textbox() - gr.Button() - with gr.Row(): - with gr.Row(): - with gr.Column(): - gr.Textbox(label="Text") - gr.Number(label="Count") - gr.Radio(choices=["One", "Two"]) - gr.Image() - with gr.Column(): - gr.Image(interactive=True) - gr.Image() - gr.Image() - gr.Textbox(label="Text") - gr.Number(label="Count") - gr.Radio(choices=["One", "Two"]) - -demo.launch() -``` - -## Calculator - -```python -import gradio as gr - -def calculator(num1, operation, num2): - if operation == "add": - return num1 + num2 - elif operation == "subtract": - return num1 - num2 - elif operation == "multiply": - return num1 * num2 - elif operation == "divide": - if num2 == 0: - raise gr.Error("Cannot divide by zero!") - return num1 / num2 - -demo = gr.Interface( - calculator, - [ - "number", - gr.Radio(["add", "subtract", "multiply", "divide"]), - "number" - ], - "number", - examples=[ - [45, "add", 3], - [3.14, "divide", 2], - [144, "multiply", 2.5], - [0, "subtract", 1.2], - ], - title="Toy Calculator", - description="Here's a sample toy calculator.", - api_name="predict" -) - -demo.launch() -``` - -## Chatbot Simple - -```python -import gradio as gr -import random -import time - -with gr.Blocks() as demo: - chatbot = gr.Chatbot() - msg = gr.Textbox() - clear = gr.ClearButton([msg, chatbot]) - - def respond(message, chat_history): - bot_message = random.choice(["How are you?", "Today is a great day", "I'm very hungry"]) - chat_history.append({"role": "user", "content": message}) - chat_history.append({"role": "assistant", "content": bot_message}) - time.sleep(2) - return "", chat_history - - msg.submit(respond, [msg, chatbot], [msg, chatbot]) - -demo.launch() -``` - -## Chatbot Streaming - -```python -import gradio as gr -import random -import time - -with gr.Blocks() as demo: - chatbot = gr.Chatbot() - msg = gr.Textbox() - clear = gr.Button("Clear") - - def user(user_message, history: list): - return "", history + [{"role": "user", "content": user_message}] - - def bot(history: list): - bot_message = random.choice(["How are you?", "I love you", "I'm very hungry"]) - history.append({"role": "assistant", "content": ""}) - for character in bot_message: - history[-1]['content'] += character - time.sleep(0.05) - yield history - - msg.submit(user, [msg, chatbot], [msg, chatbot], queue=False).then( - bot, chatbot, chatbot - ) - clear.click(lambda: None, None, chatbot, queue=False) - -demo.launch() -``` - -## Custom Css - -```python -import gradio as gr - -with gr.Blocks() as demo: - with gr.Column(elem_classes="cool-col"): - gr.Markdown("### Gradio Demo with Custom CSS", elem_classes="darktest") - gr.Markdown( - elem_classes="markdown", - value="Resize the browser window to see the CSS media query in action.", - ) - -if __name__ == "__main__": - demo.launch(css_paths=["demo/custom_css/custom_css.css"]) -``` - -## Fake Diffusion - -```python -import gradio as gr -import numpy as np -import time - -def fake_diffusion(steps): - rng = np.random.default_rng() - for i in range(steps): - time.sleep(1) - image = rng.random(size=(600, 600, 3)) - yield image - image = np.ones((1000,1000,3), np.uint8) - image[:] = [255, 124, 0] - yield image - -demo = gr.Interface(fake_diffusion, - inputs=gr.Slider(1, 10, 3, step=1), - outputs="image", - api_name="predict") - -demo.launch() -``` - -## Hello World - -```python -import gradio as gr - - -def greet(name): - return "Hello " + name + "!" - - -demo = gr.Interface(fn=greet, inputs="textbox", outputs="textbox", api_name="predict") - -demo.launch() -``` - -## Image Editor - -```python -import gradio as gr -import time - - -def sleep(im): - time.sleep(5) - return [im["background"], im["layers"][0], im["layers"][1], im["composite"]] - - -def predict(im): - return im["composite"] - - -with gr.Blocks() as demo: - with gr.Row(): - im = gr.ImageEditor( - type="numpy", - ) - im_preview = gr.Image() - n_upload = gr.Number(0, label="Number of upload events", step=1) - n_change = gr.Number(0, label="Number of change events", step=1) - n_input = gr.Number(0, label="Number of input events", step=1) - - im.upload(lambda x: x + 1, outputs=n_upload, inputs=n_upload) - im.change(lambda x: x + 1, outputs=n_change, inputs=n_change) - im.input(lambda x: x + 1, outputs=n_input, inputs=n_input) - im.change(predict, outputs=im_preview, inputs=im, show_progress="hidden") - -demo.launch() -``` - -## On Listener Decorator - -```python -import gradio as gr - -with gr.Blocks() as demo: - name = gr.Textbox(label="Name") - output = gr.Textbox(label="Output Box") - greet_btn = gr.Button("Greet") - - @gr.on(triggers=[name.submit, greet_btn.click], inputs=name, outputs=output) - def greet(name): - return "Hello " + name + "!" - -demo.launch() -``` - -## Render Merge - -```python -import gradio as gr -import time - -with gr.Blocks() as demo: - text_count = gr.Slider(1, 5, value=1, step=1, label="Textbox Count") - - @gr.render(inputs=text_count) - def render_count(count): - boxes = [] - for i in range(count): - box = gr.Textbox(label=f"Box {i}") - boxes.append(box) - - def merge(*args): - time.sleep(0.2) # simulate a delay - return " ".join(args) - - merge_btn.click(merge, boxes, output) - - def clear(): - time.sleep(0.2) # simulate a delay - return [" "] * count - - clear_btn.click(clear, None, boxes) - - def countup(): - time.sleep(0.2) # simulate a delay - return list(range(count)) - - count_btn.click(countup, None, boxes, queue=False) - - with gr.Row(): - merge_btn = gr.Button("Merge") - clear_btn = gr.Button("Clear") - count_btn = gr.Button("Count") - - output = gr.Textbox() - -demo.launch() -``` - -## Reverse Audio 2 - -```python -import gradio as gr -import numpy as np - -def reverse_audio(audio): - sr, data = audio - return (sr, np.flipud(data)) - -demo = gr.Interface(fn=reverse_audio, - inputs="microphone", - outputs="audio", api_name="predict") - -demo.launch() -``` - -## Sepia Filter - -```python -import numpy as np -import gradio as gr - -def sepia(input_img): - sepia_filter = np.array([ - [0.393, 0.769, 0.189], - [0.349, 0.686, 0.168], - [0.272, 0.534, 0.131] - ]) - sepia_img = input_img.dot(sepia_filter.T) - sepia_img /= sepia_img.max() - return sepia_img - -demo = gr.Interface(sepia, gr.Image(), "image", api_name="predict") -demo.launch() -``` - -## Sort Records - -```python -import gradio as gr - -def sort_records(records): - return records.sort("Quantity") - -demo = gr.Interface( - sort_records, - gr.Dataframe( - headers=["Item", "Quantity"], - datatype=["str", "number"], - row_count=3, - column_count=2, - column_limits=(2, 2), - type="polars" - ), - "dataframe", - description="Sort by Quantity" -) - -demo.launch() -``` - -## Streaming Simple - -```python -import gradio as gr - -with gr.Blocks() as demo: - with gr.Row(): - with gr.Column(): - input_img = gr.Image(label="Input", sources="webcam") - with gr.Column(): - output_img = gr.Image(label="Output") - input_img.stream(lambda s: s, input_img, output_img, time_limit=15, stream_every=0.1, concurrency_limit=30) - -if __name__ == "__main__": - - demo.launch() -``` - -## Tabbed Interface Lite - -```python -import gradio as gr - -hello_world = gr.Interface(lambda name: "Hello " + name, "text", "text", api_name="predict") -bye_world = gr.Interface(lambda name: "Bye " + name, "text", "text", api_name="predict") -chat = gr.ChatInterface(lambda *args: "Hello " + args[0], api_name="chat") - -demo = gr.TabbedInterface([hello_world, bye_world, chat], ["Hello World", "Bye World", "Chat"]) - -demo.launch() -``` - -## Tax Calculator - -```python -import gradio as gr - -def tax_calculator(income, marital_status, assets): - tax_brackets = [(10, 0), (25, 8), (60, 12), (120, 20), (250, 30)] - total_deductible = sum(cost for cost, deductible in zip(assets["Cost"], assets["Deductible"]) if deductible) - taxable_income = income - total_deductible - - total_tax = 0 - for bracket, rate in tax_brackets: - if taxable_income > bracket: - total_tax += (taxable_income - bracket) * rate / 100 - - if marital_status == "Married": - total_tax *= 0.75 - elif marital_status == "Divorced": - total_tax *= 0.8 - - return round(total_tax) - -demo = gr.Interface( - tax_calculator, - [ - "number", - gr.Radio(["Single", "Married", "Divorced"]), - gr.Dataframe( - headers=["Item", "Cost", "Deductible"], - datatype=["str", "number", "bool"], - label="Assets Purchased this Year", - ), - ], - gr.Number(label="Tax due"), - examples=[ - [10000, "Married", [["Suit", 5000, True], ["Laptop (for work)", 800, False], ["Car", 1800, True]]], - [80000, "Single", [["Suit", 800, True], ["Watch", 1800, True], ["Food", 800, True]]], - ], - live=True, - api_name="predict" -) - -demo.launch() -``` - -## Timer Simple - -```python -import gradio as gr -import random -import time - -with gr.Blocks() as demo: - timer = gr.Timer(1) - timestamp = gr.Number(label="Time") - timer.tick(lambda: round(time.time()), outputs=timestamp, api_name="timestamp") - - number = gr.Number(lambda: random.randint(1, 10), every=timer, label="Random Number") - with gr.Row(): - gr.Button("Start").click(lambda: gr.Timer(active=True), None, timer) - gr.Button("Stop").click(lambda: gr.Timer(active=False), None, timer) - gr.Button("Go Fast").click(lambda: 0.2, None, timer) - -if __name__ == "__main__": - demo.launch() -``` - -## Variable Outputs - -```python -import gradio as gr - -max_textboxes = 10 - -def variable_outputs(k): - k = int(k) - return [gr.Textbox(visible=True)]*k + [gr.Textbox(visible=False)]*(max_textboxes-k) - -with gr.Blocks() as demo: - s = gr.Slider(1, max_textboxes, value=max_textboxes, step=1, label="How many textboxes to show:") - textboxes = [] - for i in range(max_textboxes): - t = gr.Textbox(f"Textbox {i}") - textboxes.append(t) - - s.change(variable_outputs, s, textboxes) - -if __name__ == "__main__": - demo.launch() -``` - -## Video Identity - -```python -import gradio as gr -from gradio.media import get_video - -def video_identity(video): - return video - -# get_video() returns file paths to sample media included with Gradio -demo = gr.Interface(video_identity, - gr.Video(), - "playable_video", - examples=[ - get_video("world.mp4") - ], - cache_examples=True, - api_name="predict",) - -demo.launch() -``` From daf140b5ddecf1a647a83fcb124250e11f3ba7a7 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 10:15:28 +0100 Subject: [PATCH 08/18] remove excess project matter --- pyproject.toml | 6 ----- uv.lock | 60 -------------------------------------------------- 2 files changed, 66 deletions(-) delete mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index cca384acd5..9fd8f55e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,9 +82,3 @@ in_place = true spaces_before_inline_comment = 2 # Match Python PEP 8 spaces_indent_inline_array = 4 # Match Python PEP 8 trailing_comma_inline_array = true - -[dependency-groups] -dev = [ - "ruff>=0.15.7", - "ty>=0.0.24", -] diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 8df3cd2c40..0000000000 --- a/uv.lock +++ /dev/null @@ -1,60 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[manifest] - -[manifest.dependency-groups] -dev = [ - { name = "ruff", specifier = ">=0.15.7" }, - { name = "ty", specifier = ">=0.0.24" }, -] - -[[package]] -name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, -] - -[[package]] -name = "ty" -version = "0.0.24" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, - { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, - { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, - { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, - { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, - { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, - { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, - { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, - { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, - { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, - { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, - { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, -] From 6ddf759bff564a694a412b9429e88872ae49b846 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 10:32:16 +0100 Subject: [PATCH 09/18] fix: annotations in test --- tests/test_cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1fa08e009c..6537c1e79e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import os import subprocess From 48afd76031a3eb64bc36fb8561644e10631783f6 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 20 Mar 2026 12:38:34 +0100 Subject: [PATCH 10/18] fix: make fail_populate raise so pytest.raises assertion passes Co-Authored-By: Claude Sonnet 4.6 --- tests/test_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index e732fbd111..976cbd5fd6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3157,6 +3157,7 @@ def test_force_reinstall_preserves_previous_install_on_staging_failure(self, tmp def fail_populate(*, skill, install_dir): original_populate(skill=skill, install_dir=install_dir) install_dir.joinpath("SKILL.md").unlink() + raise RuntimeError("simulated staging failure") with patch("huggingface_hub.cli._skills._populate_install_dir", side_effect=fail_populate): with pytest.raises(RuntimeError): From 550d38c129bdd31c8d8ef60499e3e7ea13c53368 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Thu, 26 Mar 2026 15:53:45 +0100 Subject: [PATCH 11/18] Simplify hf skills install and upgrade flow --- docs/source/en/package_reference/cli.md | 45 ++- src/huggingface_hub/cli/_skills.py | 441 ++++++++---------------- src/huggingface_hub/cli/skills.py | 102 ++---- tests/test_cli.py | 375 ++++++++++++-------- uv.lock | 3 + 5 files changed, 423 insertions(+), 543 deletions(-) create mode 100644 uv.lock diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index e74245957a..3b154237a4 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3118,16 +3118,16 @@ $ hf skills [OPTIONS] COMMAND [ARGS]... **Commands**: -* `add`: Download a skill and install it for an AI... -* `preview`: Print the generated SKILL.md to stdout. -* `update`: Update installed marketplace-managed skills. +* `add`: Download a Hugging Face skill and install... +* `preview`: Print the generated `hf-cli` SKILL.md to... +* `upgrade`: Upgrade installed Hugging Face marketplace... ### `hf skills add` -Download a skill and install it for an AI assistant. +Download a Hugging Face skill and install it for an AI assistant. Default location is in the current directory (.agents/skills) or user-level (~/.agents/skills). -If custom agents are specified (e.g. --claude --codex --cursor --opencode, etc), the skill will be symlinked to the agent's skills directory. +If `--claude` is specified, the skill is also symlinked into Claude's legacy skills directory. **Usage**: @@ -3142,9 +3142,6 @@ $ hf skills add [OPTIONS] [NAME] **Options**: * `--claude`: Install for Claude. -* `--codex`: Install for Codex. -* `--cursor`: Install for Cursor. -* `--opencode`: Install for OpenCode. * `-g, --global`: Install globally (user-level) instead of in the current project directory. * `--dest PATH`: Install into a custom destination (path to skills directory). * `--force`: Overwrite existing skills in the destination. @@ -3152,9 +3149,10 @@ $ hf skills add [OPTIONS] [NAME] Examples $ hf skills add + $ hf skills add huggingface-gradio --dest=~/my-skills $ hf skills add --global - $ hf skills add --claude --cursor - $ hf skills add --codex --opencode --cursor --global + $ hf skills add --claude + $ hf skills add --claude --global Learn more Use `hf --help` for more information about a command. @@ -3163,7 +3161,7 @@ Learn more ### `hf skills preview` -Print the generated SKILL.md to stdout. +Print the generated `hf-cli` SKILL.md to stdout. **Usage**: @@ -3175,36 +3173,33 @@ $ hf skills preview [OPTIONS] * `--help`: Show this message and exit. -### `hf skills update` +### `hf skills upgrade` -Update installed marketplace-managed skills. +Upgrade installed Hugging Face marketplace skills. **Usage**: ```console -$ hf skills update [OPTIONS] [NAME] +$ hf skills upgrade [OPTIONS] [NAME] ``` **Arguments**: -* `[NAME]`: Optional installed skill name to update. +* `[NAME]`: Optional installed skill name to upgrade. **Options**: -* `--claude`: Update skills installed for Claude. -* `--codex`: Update skills installed for Codex. -* `--cursor`: Update skills installed for Cursor. -* `--opencode`: Update skills installed for OpenCode. +* `--claude`: Upgrade skills installed for Claude. * `-g, --global`: Use global skills directories instead of the current project. -* `--dest PATH`: Update skills in a custom skills directory. -* `--force`: Overwrite local modifications when updating a skill. +* `--dest PATH`: Upgrade skills in a custom skills directory. +* `--force`: Overwrite local modifications when upgrading a skill. * `--help`: Show this message and exit. Examples - $ hf skills update - $ hf skills update hf-cli - $ hf skills update gradio --dest=~/my-skills - $ hf skills update --claude --force + $ hf skills upgrade + $ hf skills upgrade hf-cli + $ hf skills upgrade huggingface-gradio --dest=~/my-skills + $ hf skills upgrade --claude --force Learn more Use `hf --help` for more information about a command. diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index 79c42bf833..7fc8a9e975 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -1,31 +1,31 @@ -"""Internal helpers for marketplace-backed skill installation and updates.""" +"""Internal helpers for Hugging Face marketplace skill installation and upgrades.""" from __future__ import annotations +import base64 import hashlib import io import json import shutil -import subprocess import tarfile import tempfile from dataclasses import dataclass -from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any, Literal -from urllib.parse import urlparse from huggingface_hub.errors import CLIError from huggingface_hub.utils import get_session -DEFAULT_SKILLS_REPO = "https://github.com/huggingface/skills" +DEFAULT_SKILLS_REPO_ID = "huggingface/skills" +DEFAULT_SKILLS_REPO_OWNER = "huggingface" +DEFAULT_SKILLS_REPO_NAME = "skills" +DEFAULT_SKILLS_REF = "main" MARKETPLACE_PATH = ".claude-plugin/marketplace.json" -MARKETPLACE_TIMEOUT = 10 -SKILL_SOURCE_FILENAME = ".skill-source.json" -SKILL_SOURCE_SCHEMA_VERSION = 1 +GITHUB_API_TIMEOUT = 10 +SKILL_MANIFEST_FILENAME = ".hf-skill-manifest.json" +SKILL_MANIFEST_SCHEMA_VERSION = 1 -SkillSourceOrigin = Literal["remote", "local"] SkillUpdateStatus = Literal[ "dirty", "up_to_date", @@ -40,33 +40,13 @@ @dataclass(frozen=True) class MarketplaceSkill: name: str - description: str | None - repo_url: str - repo_ref: str | None repo_path: str - source_url: str | None = None - - @property - def install_dir_name(self) -> str: - path = PurePosixPath(self.repo_path) - if path.name.lower() == "skill.md": - return path.parent.name or self.name - return path.name or self.name @dataclass(frozen=True) -class InstalledSkillSource: +class InstalledSkillManifest: schema_version: int - installed_via: str - source_origin: SkillSourceOrigin - repo_url: str - repo_ref: str | None - repo_path: str - source_url: str | None - installed_commit: str | None - installed_path_oid: str | None installed_revision: str - installed_at: str content_fingerprint: str @@ -80,10 +60,9 @@ class SkillUpdateInfo: available_revision: str | None = None -def load_marketplace_skills(repo_url: str | None = None) -> list[MarketplaceSkill]: - """Load marketplace skills from the default Hugging Face skills repository or a local override.""" - repo_url = repo_url or DEFAULT_SKILLS_REPO - payload = _load_marketplace_payload(repo_url) +def load_marketplace_skills() -> list[MarketplaceSkill]: + """Load skills from the default Hugging Face marketplace.""" + payload = _load_marketplace_payload() plugins = payload.get("plugins") if not isinstance(plugins, list): raise CLIError("Invalid marketplace payload: expected a top-level 'plugins' list.") @@ -96,28 +75,16 @@ def load_marketplace_skills(repo_url: str | None = None) -> list[MarketplaceSkil source = plugin.get("source") if not isinstance(name, str) or not isinstance(source, str): continue - description = plugin.get("description") if isinstance(plugin.get("description"), str) else None - skills.append( - MarketplaceSkill( - name=name, - description=description, - repo_url=repo_url, - repo_ref=None, - repo_path=_normalize_repo_path(source), - source_url=None, - ) - ) + skills.append(MarketplaceSkill(name=name, repo_path=_normalize_repo_path(source))) return skills -def get_marketplace_skill(selector: str, repo_url: str | None = None) -> MarketplaceSkill: +def get_marketplace_skill(selector: str) -> MarketplaceSkill: """Resolve a marketplace skill by name.""" - repo_url = repo_url or DEFAULT_SKILLS_REPO - skills = load_marketplace_skills(repo_url) - selected = _select_marketplace_skill(skills, selector) + selected = _select_marketplace_skill(load_marketplace_skills(), selector) if selected is None: raise CLIError( - f"Skill '{selector}' not found in huggingface/skills. " + f"Skill '{selector}' not found in {DEFAULT_SKILLS_REPO_ID}. " "Try `hf skills add` to install `hf-cli` or use a known skill name." ) return selected @@ -127,7 +94,7 @@ def install_marketplace_skill(skill: MarketplaceSkill, destination_root: Path, f """Install a marketplace skill into a local skills directory.""" destination_root = destination_root.expanduser().resolve() destination_root.mkdir(parents=True, exist_ok=True) - install_dir = destination_root / skill.install_dir_name + install_dir = destination_root / skill.name if install_dir.exists() and not force: raise FileExistsError(f"Skill already exists: {install_dir}") @@ -154,7 +121,8 @@ def check_for_updates( selector: str | None = None, ) -> list[SkillUpdateInfo]: """Check managed skill installs for newer upstream revisions.""" - updates = [_evaluate_update(skill_dir) for skill_dir in _iter_unique_skill_dirs(roots)] + marketplace_skills = {skill.name.lower(): skill for skill in load_marketplace_skills()} + updates = [_evaluate_update(skill_dir, marketplace_skills) for skill_dir in _iter_unique_skill_dirs(roots)] filtered = _filter_updates(updates, selector) if selector is not None and not filtered: raise CLIError(f"No installed skills match '{selector}'.") @@ -166,7 +134,7 @@ def apply_updates( selector: str | None = None, force: bool = False, ) -> list[SkillUpdateInfo]: - """Update managed skills in place, skipping dirty installs unless forced.""" + """Upgrade managed skills in place, skipping dirty installs unless forced.""" updates = check_for_updates(roots, selector) results: list[SkillUpdateInfo] = [] for update in updates: @@ -175,13 +143,13 @@ def apply_updates( def compute_skill_content_fingerprint(skill_dir: Path) -> str: - """Hash installed skill contents while ignoring the provenance sidecar.""" + """Hash installed skill contents while ignoring the local manifest.""" digest = hashlib.sha256() root = skill_dir.resolve() - sidecar_path = root / SKILL_SOURCE_FILENAME + manifest_path = root / SKILL_MANIFEST_FILENAME for path in sorted(root.rglob("*")): - if path == sidecar_path or not path.is_file(): + if path == manifest_path or not path.is_file(): continue digest.update(path.relative_to(root).as_posix().encode("utf-8")) digest.update(b"\0") @@ -191,64 +159,57 @@ def compute_skill_content_fingerprint(skill_dir: Path) -> str: return f"sha256:{digest.hexdigest()}" -def read_installed_skill_source(skill_dir: Path) -> tuple[InstalledSkillSource | None, str | None]: - """Read installed skill provenance metadata from the local sidecar file.""" - sidecar_path = skill_dir / SKILL_SOURCE_FILENAME - if not sidecar_path.exists(): +def read_installed_skill_manifest(skill_dir: Path) -> tuple[InstalledSkillManifest | None, str | None]: + """Read local skill metadata written by `hf skills add`.""" + manifest_path = skill_dir / SKILL_MANIFEST_FILENAME + if not manifest_path.exists(): return None, None try: - payload = json.loads(sidecar_path.read_text(encoding="utf-8")) + payload = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 return None, f"invalid json: {exc}" if not isinstance(payload, dict): return None, "metadata root must be an object" try: - return _parse_installed_skill_source(payload), None + return _parse_installed_skill_manifest(payload), None except ValueError as exc: return None, str(exc) -def write_installed_skill_source(skill_dir: Path, source: InstalledSkillSource) -> None: +def write_installed_skill_manifest(skill_dir: Path, manifest: InstalledSkillManifest) -> None: payload = { - "schema_version": source.schema_version, - "installed_via": source.installed_via, - "source_origin": source.source_origin, - "repo_url": source.repo_url, - "repo_ref": source.repo_ref, - "repo_path": source.repo_path, - "source_url": source.source_url, - "installed_commit": source.installed_commit, - "installed_path_oid": source.installed_path_oid, - "installed_revision": source.installed_revision, - "installed_at": source.installed_at, - "content_fingerprint": source.content_fingerprint, + "schema_version": manifest.schema_version, + "installed_revision": manifest.installed_revision, + "content_fingerprint": manifest.content_fingerprint, } - (skill_dir / SKILL_SOURCE_FILENAME).write_text( + (skill_dir / SKILL_MANIFEST_FILENAME).write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) -def _load_marketplace_payload(repo_url: str) -> dict[str, Any]: - if _is_local_repo(repo_url): - marketplace_path = _resolve_local_repo_path(repo_url) / MARKETPLACE_PATH - if not marketplace_path.is_file(): - raise CLIError(f"Marketplace file not found: {marketplace_path}") - try: - payload = json.loads(marketplace_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise CLIError(f"Failed to parse marketplace file: {exc}") from exc - if not isinstance(payload, dict): - raise CLIError("Invalid marketplace payload: expected a JSON object.") - return payload - - raw_url = _raw_github_url(repo_url, "main", MARKETPLACE_PATH) - response = get_session().get(raw_url, follow_redirects=True, timeout=MARKETPLACE_TIMEOUT) - response.raise_for_status() - payload = response.json() +def _load_marketplace_payload() -> dict[str, Any]: + payload = _github_api_get_json( + f"contents/{MARKETPLACE_PATH}", + params={"ref": DEFAULT_SKILLS_REF}, + ) if not isinstance(payload, dict): + raise CLIError("Invalid marketplace response: expected a JSON object.") + + content = payload.get("content") + encoding = payload.get("encoding") + if not isinstance(content, str) or encoding != "base64": + raise CLIError("Invalid marketplace payload: expected base64-encoded content.") + + try: + decoded = base64.b64decode(content).decode("utf-8") + parsed = json.loads(decoded) + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to decode marketplace payload: {exc}") from exc + + if not isinstance(parsed, dict): raise CLIError("Invalid marketplace payload: expected a JSON object.") - return payload + return parsed def _select_marketplace_skill(skills: list[MarketplaceSkill], selector: str) -> MarketplaceSkill | None: @@ -270,41 +231,20 @@ def _normalize_repo_path(path: str) -> str: def _populate_install_dir(skill: MarketplaceSkill, install_dir: Path) -> None: - metadata = _resolve_source_metadata(skill) + installed_revision = _resolve_available_revision(skill) install_dir.mkdir(parents=True, exist_ok=True) - - if metadata.source_origin == "local": - _extract_local_git_path( - repo_path=_resolve_local_repo_path(skill.repo_url), - repo_ref=metadata.install_ref, - source_path=skill.repo_path, - install_dir=install_dir, - ) - else: - _extract_remote_github_path( - repo_url=skill.repo_url, - revision=metadata.installed_revision, - source_path=skill.repo_path, - install_dir=install_dir, - ) - + _extract_remote_github_path( + revision=installed_revision, + source_path=skill.repo_path, + install_dir=install_dir, + ) _validate_installed_skill_dir(install_dir) - fingerprint = compute_skill_content_fingerprint(install_dir) - write_installed_skill_source( + write_installed_skill_manifest( install_dir, - InstalledSkillSource( - schema_version=SKILL_SOURCE_SCHEMA_VERSION, - installed_via="marketplace", - source_origin=metadata.source_origin, - repo_url=skill.repo_url, - repo_ref=skill.repo_ref, - repo_path=skill.repo_path, - source_url=skill.source_url, - installed_commit=metadata.installed_commit, - installed_path_oid=None, - installed_revision=metadata.installed_revision, - installed_at=_iso_utc_now(), - content_fingerprint=fingerprint, + InstalledSkillManifest( + schema_version=SKILL_MANIFEST_SCHEMA_VERSION, + installed_revision=installed_revision, + content_fingerprint=compute_skill_content_fingerprint(install_dir), ), ) @@ -316,61 +256,16 @@ def _validate_installed_skill_dir(skill_dir: Path) -> None: skill_file.read_text(encoding="utf-8") -@dataclass(frozen=True) -class _ResolvedSourceMetadata: - source_origin: SkillSourceOrigin - install_ref: str - installed_commit: str | None - installed_revision: str - - -def _resolve_source_metadata(skill: MarketplaceSkill) -> _ResolvedSourceMetadata: - if _is_local_repo(skill.repo_url): - repo_path = _resolve_local_repo_path(skill.repo_url) - install_ref = skill.repo_ref or "HEAD" - installed_commit = _git_stdout(repo_path, "rev-parse", install_ref) - return _ResolvedSourceMetadata( - source_origin="local", - install_ref=install_ref, - installed_commit=installed_commit, - installed_revision=installed_commit, - ) - - installed_commit = _git_ls_remote(skill.repo_url, skill.repo_ref) - return _ResolvedSourceMetadata( - source_origin="remote", - install_ref=installed_commit, - installed_commit=installed_commit, - installed_revision=installed_commit, - ) - - -def _extract_local_git_path(repo_path: Path, repo_ref: str, source_path: str, install_dir: Path) -> None: - proc = subprocess.run( - ["git", "-C", str(repo_path), "archive", "--format=tar", repo_ref, source_path], - check=False, - capture_output=True, - text=False, - ) - if proc.returncode != 0: - stderr = proc.stderr.decode("utf-8", errors="replace").strip() - raise FileNotFoundError(stderr or f"Path '{source_path}' not found in {repo_ref}.") - _extract_tar_subpath(proc.stdout, source_path=source_path, install_dir=install_dir) - - -def _extract_remote_github_path(repo_url: str, revision: str, source_path: str, install_dir: Path) -> None: - owner, repo = _parse_github_repo(repo_url) - tarball_url = f"https://codeload.github.com/{owner}/{repo}/tar.gz/{revision}" - response = get_session().get(tarball_url, follow_redirects=True, timeout=MARKETPLACE_TIMEOUT) - response.raise_for_status() - _extract_tar_subpath(response.content, source_path=source_path, install_dir=install_dir) +def _extract_remote_github_path(revision: str, source_path: str, install_dir: Path) -> None: + tar_bytes = _github_api_get_bytes(f"tarball/{revision}") + _extract_tar_subpath(tar_bytes, source_path=source_path, install_dir=install_dir) def _extract_tar_subpath(tar_bytes: bytes, source_path: str, install_dir: Path) -> None: - """Extract a skill subdirectory from either a git archive or a GitHub tarball. + """Extract a skill subdirectory from a tar archive. - Local `git archive` paths start directly at `skills//...`, while GitHub tarballs - include a leading `-/` directory. This helper accepts both layouts. + GitHub tarballs include a leading `-/` directory. The helper also + accepts archives that start directly at `skills//...` to keep tests simple. """ source_parts = PurePosixPath(source_path).parts with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:*") as archive: @@ -443,9 +338,9 @@ def _iter_unique_skill_dirs(roots: list[Path]) -> list[Path]: return discovered -def _evaluate_update(skill_dir: Path) -> SkillUpdateInfo: - source, error = read_installed_skill_source(skill_dir) - if source is None: +def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceSkill]) -> SkillUpdateInfo: + manifest, error = read_installed_skill_manifest(skill_dir) + if manifest is None: return SkillUpdateInfo( name=skill_dir.name, skill_dir=skill_dir, @@ -453,9 +348,19 @@ def _evaluate_update(skill_dir: Path) -> SkillUpdateInfo: detail=error, ) - current_revision = source.installed_revision + skill = marketplace_skills.get(skill_dir.name.lower()) + if skill is None: + return SkillUpdateInfo( + name=skill_dir.name, + skill_dir=skill_dir, + status="source_unreachable", + detail=f"Skill '{skill_dir.name}' is no longer available in {DEFAULT_SKILLS_REPO_ID}.", + current_revision=manifest.installed_revision, + ) + + current_revision = manifest.installed_revision try: - available_revision = _resolve_available_revision(source) + available_revision = _resolve_available_revision(skill) except Exception as exc: return SkillUpdateInfo( name=skill_dir.name, @@ -466,7 +371,7 @@ def _evaluate_update(skill_dir: Path) -> SkillUpdateInfo: ) fingerprint = compute_skill_content_fingerprint(skill_dir) - if fingerprint != source.content_fingerprint: + if fingerprint != manifest.content_fingerprint: return SkillUpdateInfo( name=skill_dir.name, skill_dir=skill_dir, @@ -501,9 +406,9 @@ def _apply_single_update(update: SkillUpdateInfo, *, force: bool) -> SkillUpdate if update.status == "dirty" and not force: return update - source, error = read_installed_skill_source(update.skill_dir) - if source is None: - detail = error or "missing source metadata" + manifest, error = read_installed_skill_manifest(update.skill_dir) + if manifest is None: + detail = error or "missing skill manifest" return SkillUpdateInfo( name=update.name, skill_dir=update.skill_dir, @@ -513,17 +418,10 @@ def _apply_single_update(update: SkillUpdateInfo, *, force: bool) -> SkillUpdate available_revision=update.available_revision, ) - skill = MarketplaceSkill( - name=update.name, - description=None, - repo_url=source.repo_url, - repo_ref=source.repo_ref, - repo_path=source.repo_path, - source_url=source.source_url, - ) try: + skill = get_marketplace_skill(update.skill_dir.name) install_marketplace_skill(skill, update.skill_dir.parent, force=True) - refreshed = _evaluate_update(update.skill_dir) + refreshed = _evaluate_update(update.skill_dir, {skill.name.lower(): skill}) except Exception as exc: return SkillUpdateInfo( name=update.name, @@ -551,129 +449,66 @@ def _filter_updates(updates: list[SkillUpdateInfo], selector: str | None) -> lis return [update for update in updates if update.name.lower() == selector_lower] -def _resolve_available_revision(source: InstalledSkillSource) -> str: - if source.source_origin == "local": - repo_path = _resolve_local_repo_path(source.repo_url) - return _git_stdout(repo_path, "rev-parse", source.repo_ref or "HEAD") - return _git_ls_remote(source.repo_url, source.repo_ref) +def _resolve_available_revision(skill: MarketplaceSkill) -> str: + payload = _github_api_get_json( + "commits", + params={"sha": DEFAULT_SKILLS_REF, "path": skill.repo_path, "per_page": 1}, + ) + if not isinstance(payload, list) or not payload: + raise CLIError(f"Unable to resolve the current revision for skill '{skill.name}'.") + + latest = payload[0] + if not isinstance(latest, dict): + raise CLIError(f"Invalid commit response while resolving skill '{skill.name}'.") + revision = latest.get("sha") + if not isinstance(revision, str) or not revision: + raise CLIError(f"Invalid commit response while resolving skill '{skill.name}'.") + return revision -def _parse_installed_skill_source(payload: dict[str, Any]) -> InstalledSkillSource: - if payload.get("schema_version") != SKILL_SOURCE_SCHEMA_VERSION: + +def _parse_installed_skill_manifest(payload: dict[str, Any]) -> InstalledSkillManifest: + if payload.get("schema_version") != SKILL_MANIFEST_SCHEMA_VERSION: raise ValueError(f"unsupported schema_version: {payload.get('schema_version')}") - repo_url = payload.get("repo_url") - repo_path = payload.get("repo_path") - source_origin = payload.get("source_origin") - installed_via = payload.get("installed_via") - installed_revision = payload.get("installed_revision") - installed_at = payload.get("installed_at") - - if not isinstance(repo_url, str) or not repo_url: - raise ValueError("missing repo_url") - if not isinstance(repo_path, str) or not repo_path: - raise ValueError("missing repo_path") - if source_origin not in {"local", "remote"}: - raise ValueError("invalid source_origin") - if not isinstance(installed_via, str) or not installed_via: - raise ValueError("missing installed_via") - if not isinstance(installed_revision, str) or not installed_revision: - raise ValueError("missing installed_revision") - if not isinstance(installed_at, str) or not installed_at: - raise ValueError("missing installed_at") - repo_ref = payload.get("repo_ref") - source_url = payload.get("source_url") - installed_commit = payload.get("installed_commit") - installed_path_oid = payload.get("installed_path_oid") + installed_revision = payload.get("installed_revision") content_fingerprint = payload.get("content_fingerprint") - if repo_ref is not None and not isinstance(repo_ref, str): - raise ValueError("invalid repo_ref") - if source_url is not None and not isinstance(source_url, str): - raise ValueError("invalid source_url") - if installed_commit is not None and not isinstance(installed_commit, str): - raise ValueError("invalid installed_commit") - if installed_path_oid is not None and not isinstance(installed_path_oid, str): - raise ValueError("invalid installed_path_oid") + if not isinstance(installed_revision, str) or not installed_revision: + raise ValueError("missing installed_revision") if not isinstance(content_fingerprint, str) or not content_fingerprint: raise ValueError("missing content_fingerprint") - return InstalledSkillSource( - schema_version=SKILL_SOURCE_SCHEMA_VERSION, - installed_via=installed_via, - source_origin=source_origin, - repo_url=repo_url, - repo_ref=repo_ref, - repo_path=repo_path, - source_url=source_url, - installed_commit=installed_commit, - installed_path_oid=installed_path_oid, + return InstalledSkillManifest( + schema_version=SKILL_MANIFEST_SCHEMA_VERSION, installed_revision=installed_revision, - installed_at=installed_at, content_fingerprint=content_fingerprint, ) -def _is_local_repo(repo_url: str) -> bool: - parsed = urlparse(repo_url) - return parsed.scheme in {"", "file"} - - -def _resolve_local_repo_path(repo_url: str) -> Path: - parsed = urlparse(repo_url) - if parsed.scheme == "file": - return Path(parsed.path).expanduser().resolve() - return Path(repo_url).expanduser().resolve() - - -def _raw_github_url(repo_url: str, revision: str, path: str) -> str: - owner, repo = _parse_github_repo(repo_url) - return f"https://raw.githubusercontent.com/{owner}/{repo}/{revision}/{path}" +def _github_api_get_json(endpoint: str, params: dict[str, Any] | None = None) -> Any: + response = _github_api_get(endpoint, params=params) + try: + return response.json() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to decode GitHub API response for '{endpoint}': {exc}") from exc -def _parse_github_repo(repo_url: str) -> tuple[str, str]: - parsed = urlparse(repo_url) - if parsed.netloc not in {"github.com", "www.github.com"}: - raise CLIError(f"Unsupported skills repository URL: {repo_url}") - parts = [part for part in parsed.path.strip("/").split("/") if part] - if len(parts) < 2: - raise CLIError(f"Unsupported skills repository URL: {repo_url}") - repo = parts[1] - if repo.endswith(".git"): - repo = repo[:-4] - return parts[0], repo +def _github_api_get_bytes(endpoint: str, params: dict[str, Any] | None = None) -> bytes: + return _github_api_get(endpoint, params=params).content -def _git_stdout(repo_path: Path, *args: str) -> str: - proc = subprocess.run( - ["git", "-C", str(repo_path), *args], - check=False, - capture_output=True, - text=True, - ) - if proc.returncode != 0: - stderr = proc.stderr.strip() or proc.stdout.strip() - raise CLIError(stderr or f"git {' '.join(args)} failed") - return proc.stdout.strip() - - -def _git_ls_remote(repo_url: str, repo_ref: str | None) -> str: - ref = repo_ref or "HEAD" - proc = subprocess.run( - ["git", "ls-remote", repo_url, ref], - check=False, - capture_output=True, - text=True, - ) - if proc.returncode != 0: - stderr = proc.stderr.strip() or proc.stdout.strip() - raise CLIError(stderr or f"Unable to resolve {ref} for {repo_url}") - for line in proc.stdout.splitlines(): - parts = line.split() - if parts: - return parts[0] - raise CLIError(f"Unable to resolve {ref} for {repo_url}") - - -def _iso_utc_now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") +def _github_api_get(endpoint: str, params: dict[str, Any] | None = None): + url = f"https://api.github.com/repos/{DEFAULT_SKILLS_REPO_OWNER}/{DEFAULT_SKILLS_REPO_NAME}/{endpoint.lstrip('/')}" + try: + response = get_session().get( + url, + params=params, + headers={"Accept": "application/vnd.github+json"}, + follow_redirects=True, + timeout=GITHUB_API_TIMEOUT, + ) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to fetch '{endpoint}' from {DEFAULT_SKILLS_REPO_ID}: {exc}") from exc + return response diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index 48092e2a2f..7821b8ed5e 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -21,12 +21,6 @@ # install the hf-cli skill for Claude (project-level, in current directory) hf skills add --claude - # install for Cursor (project-level, in current directory) - hf skills add --cursor - - # install for multiple assistants (project-level) - hf skills add --claude --codex --opencode --cursor - # install globally (user-level) hf skills add --claude --global @@ -76,20 +70,8 @@ CENTRAL_LOCAL = Path(".agents/skills") CENTRAL_GLOBAL = Path("~/.agents/skills") - -GLOBAL_TARGETS = { - "codex": Path("~/.codex/skills"), - "claude": Path("~/.claude/skills"), - "cursor": Path("~/.cursor/skills"), - "opencode": Path("~/.config/opencode/skills"), -} - -LOCAL_TARGETS = { - "codex": Path(".codex/skills"), - "claude": Path(".claude/skills"), - "cursor": Path(".cursor/skills"), - "opencode": Path(".opencode/skills"), -} +CLAUDE_LOCAL = Path(".claude/skills") +CLAUDE_GLOBAL = Path("~/.claude/skills") # Flags worth explaining in the common-options glossary. Self-explanatory flags # (--namespace, --yes, --private, …) are omitted even if they appear frequently. _COMMON_FLAG_ALLOWLIST = {"--token", "--quiet", "--type", "--format", "--revision"} @@ -305,36 +287,23 @@ def _create_symlink(agent_skills_dir: Path, skill_name: str, central_skill_path: def _resolve_update_roots( *, claude: bool, - codex: bool, - cursor: bool, - opencode: bool, global_: bool, dest: Optional[Path], ) -> list[Path]: if dest is not None: - if claude or codex or cursor or opencode or global_: - raise CLIError("--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global.") + if claude or global_: + raise CLIError("--dest cannot be combined with --claude or --global.") return [dest.expanduser().resolve()] - targets_dict = GLOBAL_TARGETS if global_ else LOCAL_TARGETS roots: list[Path] = [CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL] - if not any([claude, codex, cursor, opencode]): - roots.extend(targets_dict.values()) - else: - if claude: - roots.append(targets_dict["claude"]) - if codex: - roots.append(targets_dict["codex"]) - if cursor: - roots.append(targets_dict["cursor"]) - if opencode: - roots.append(targets_dict["opencode"]) + if claude: + roots.append(CLAUDE_GLOBAL if global_ else CLAUDE_LOCAL) return [root.expanduser().resolve() for root in roots] @skills_cli.command("preview") def skills_preview() -> None: - """Print the generated SKILL.md to stdout.""" + """Print the generated `hf-cli` SKILL.md to stdout.""" print(build_skill_md()) @@ -342,9 +311,10 @@ def skills_preview() -> None: "add", examples=[ "hf skills add", + "hf skills add huggingface-gradio --dest=~/my-skills", "hf skills add --global", - "hf skills add --claude --cursor", - "hf skills add --codex --opencode --cursor --global", + "hf skills add --claude", + "hf skills add --claude --global", ], ) def skills_add( @@ -353,9 +323,6 @@ def skills_add( typer.Argument(help="Marketplace skill name.", show_default=False), ] = DEFAULT_SKILL_ID, claude: Annotated[bool, typer.Option("--claude", help="Install for Claude.")] = False, - codex: Annotated[bool, typer.Option("--codex", help="Install for Codex.")] = False, - cursor: Annotated[bool, typer.Option("--cursor", help="Install for Cursor.")] = False, - opencode: Annotated[bool, typer.Option("--opencode", help="Install for OpenCode.")] = False, global_: Annotated[ bool, typer.Option( @@ -378,15 +345,15 @@ def skills_add( ), ] = False, ) -> None: - """Download a skill and install it for an AI assistant. + """Download a Hugging Face skill and install it for an AI assistant. Default location is in the current directory (.agents/skills) or user-level (~/.agents/skills). - If custom agents are specified (e.g. --claude --codex --cursor --opencode, etc), the skill will be symlinked to the agent's skills directory. + If `--claude` is specified, the skill is also symlinked into Claude's legacy skills directory. """ try: if dest: - if claude or codex or cursor or opencode or global_: - print("--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global.") + if claude or global_: + print("--dest cannot be combined with --claude or --global.") raise typer.Exit(code=1) skill_dest = _install_to(dest, name, force) print(f"Installed '{name}' to {skill_dest}") @@ -397,19 +364,8 @@ def skills_add( central_skill_path = _install_to(central_path, name, force) print(f"Installed '{name}' to central location: {central_skill_path}") - # Create symlinks in agent directories - targets_dict = GLOBAL_TARGETS if global_ else LOCAL_TARGETS - agent_targets: list[Path] = [] if claude: - agent_targets.append(targets_dict["claude"]) - if codex: - agent_targets.append(targets_dict["codex"]) - if cursor: - agent_targets.append(targets_dict["cursor"]) - if opencode: - agent_targets.append(targets_dict["opencode"]) - - for agent_target in agent_targets: + agent_target = CLAUDE_GLOBAL if global_ else CLAUDE_LOCAL link_path = _create_symlink(agent_target, name, central_skill_path, force) print(f"Created symlink: {link_path}") except CLIError as exc: @@ -418,23 +374,20 @@ def skills_add( @skills_cli.command( - "update", + "upgrade", examples=[ - "hf skills update", - "hf skills update hf-cli", - "hf skills update gradio --dest=~/my-skills", - "hf skills update --claude --force", + "hf skills upgrade", + "hf skills upgrade hf-cli", + "hf skills upgrade huggingface-gradio --dest=~/my-skills", + "hf skills upgrade --claude --force", ], ) -def skills_update( +def skills_upgrade( name: Annotated[ Optional[str], - typer.Argument(help="Optional installed skill name to update.", show_default=False), + typer.Argument(help="Optional installed skill name to upgrade.", show_default=False), ] = None, - claude: Annotated[bool, typer.Option("--claude", help="Update skills installed for Claude.")] = False, - codex: Annotated[bool, typer.Option("--codex", help="Update skills installed for Codex.")] = False, - cursor: Annotated[bool, typer.Option("--cursor", help="Update skills installed for Cursor.")] = False, - opencode: Annotated[bool, typer.Option("--opencode", help="Update skills installed for OpenCode.")] = False, + claude: Annotated[bool, typer.Option("--claude", help="Upgrade skills installed for Claude.")] = False, global_: Annotated[ bool, typer.Option( @@ -446,24 +399,21 @@ def skills_update( dest: Annotated[ Optional[Path], typer.Option( - help="Update skills in a custom skills directory.", + help="Upgrade skills in a custom skills directory.", ), ] = None, force: Annotated[ bool, typer.Option( "--force", - help="Overwrite local modifications when updating a skill.", + help="Overwrite local modifications when upgrading a skill.", ), ] = False, ) -> None: - """Update installed marketplace-managed skills.""" + """Upgrade installed Hugging Face marketplace skills.""" try: roots = _resolve_update_roots( claude=claude, - codex=codex, - cursor=cursor, - opencode=opencode, global_=global_, dest=dest, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 976cbd5fd6..63b5d9a35f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,13 +1,15 @@ from __future__ import annotations +import base64 +import io import json import os -import subprocess +import tarfile import warnings from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace -from typing import Generator, Optional +from typing import Any, Generator, Optional from unittest.mock import Mock, patch import pytest @@ -2906,59 +2908,123 @@ def test_collect_leaf_commands_finds_deeply_nested(self) -> None: assert any("jobs uv run" in p for p in leaf_paths) -def _git(repo: Path, *args: str) -> str: - result = subprocess.run( - ["git", "-C", str(repo), *args], - check=True, - capture_output=True, - text=True, - ) - return result.stdout.strip() - - -def _commit_all(repo: Path, message: str) -> str: - _git(repo, "add", ".") - _git(repo, "commit", "-m", message) - return _git(repo, "rev-parse", "HEAD") - - -def _create_skills_repo(root: Path, descriptions: dict[str, str], bodies: dict[str, str] | None = None) -> Path: - repo = root / "skills-repo" - repo.mkdir(parents=True) - subprocess.run(["git", "init", str(repo)], check=True, capture_output=True, text=True) - _git(repo, "config", "user.email", "tests@example.com") - _git(repo, "config", "user.name", "Test User") - - bodies = bodies or {} - plugins = [] - for name, description in descriptions.items(): - skill_dir = repo / "skills" / name - skill_dir.mkdir(parents=True, exist_ok=True) - skill_dir.joinpath("SKILL.md").write_text( - f"---\nname: {name}\ndescription: {description}\n---\n\n{bodies.get(name, f'# {name}')}\n", - encoding="utf-8", - ) - skill_dir.joinpath("notes.txt").write_text(f"{name} helper file\n", encoding="utf-8") - plugins.append( - { - "name": name, - "source": f"./skills/{name}", - "skills": "./", - "description": description, +class _FakeGitHubResponse: + def __init__(self, *, json_data: Any | None = None, content: bytes = b"", status_code: int = 200) -> None: + self._json_data = json_data + self.content = content + self.status_code = status_code + + def json(self) -> Any: + return self._json_data + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +def _render_marketplace_skill(name: str, description: str, body: str) -> str: + return f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n" + + +def _create_marketplace_tarball(root_dir: str, files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for path, content in files.items(): + encoded = content.encode("utf-8") + info = tarfile.TarInfo(name=f"{root_dir}/{path}") + info.size = len(encoded) + archive.addfile(info, io.BytesIO(encoded)) + return buffer.getvalue() + + +class _FakeSkillsMarketplaceSession: + def __init__(self, descriptions: dict[str, str], bodies: dict[str, str] | None = None) -> None: + self._revision_counter = 1 + self._skills: dict[str, dict[str, Any]] = {} + bodies = bodies or {} + + for name, description in descriptions.items(): + self.add_skill(name, description=description, body=bodies.get(name, f"# {name}")) + + def add_skill(self, name: str, *, description: str, body: str, source_path: str | None = None) -> str: + self._skills[name] = { + "description": description, + "source_path": source_path or f"skills/{name}", + "revisions": [], + } + return self.add_revision(name, body=body) + + def add_revision(self, name: str, *, body: str) -> str: + revision = f"{self._revision_counter:040x}" + self._revision_counter += 1 + self._skills[name]["revisions"].append({"sha": revision, "body": body}) + return revision + + def latest_revision(self, name: str) -> str: + return self._skills[name]["revisions"][-1]["sha"] + + def get(self, url: str, **kwargs: Any) -> _FakeGitHubResponse: + prefix = "https://api.github.com/repos/huggingface/skills/" + assert url.startswith(prefix), url + endpoint = url[len(prefix) :] + params = kwargs.get("params") or {} + + if endpoint == "contents/.claude-plugin/marketplace.json": + payload = { + "plugins": [ + { + "name": name, + "source": f"./{skill['source_path']}", + "skills": "./", + "description": skill["description"], + } + for name, skill in sorted(self._skills.items()) + ] } - ) - - marketplace = repo / ".claude-plugin" / "marketplace.json" - marketplace.parent.mkdir(parents=True, exist_ok=True) - marketplace.write_text(json.dumps({"plugins": plugins}, indent=2), encoding="utf-8") - _commit_all(repo, "initial") - return repo + encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") + return _FakeGitHubResponse(json_data={"encoding": "base64", "content": encoded}) + + if endpoint == "commits": + source_path = params["path"] + for skill in self._skills.values(): + if skill["source_path"] == source_path: + return _FakeGitHubResponse(json_data=[{"sha": skill["revisions"][-1]["sha"]}]) + return _FakeGitHubResponse(status_code=404, json_data={"message": "Not Found"}) + + if endpoint.startswith("tarball/"): + requested_revision = endpoint.split("/", 1)[1] + for name, skill in self._skills.items(): + for revision in skill["revisions"]: + if revision["sha"] != requested_revision: + continue + tarball = _create_marketplace_tarball( + f"skills-{requested_revision[:7]}", + { + f"{skill['source_path']}/SKILL.md": _render_marketplace_skill( + name, + skill["description"], + revision["body"], + ), + f"{skill['source_path']}/notes.txt": f"{name} helper file\n", + }, + ) + return _FakeGitHubResponse(content=tarball) + return _FakeGitHubResponse(status_code=404, json_data={"message": "Not Found"}) + + raise AssertionError(f"Unexpected GitHub API request: {url}") + + +def _patch_skills_marketplace( + monkeypatch, descriptions: dict[str, str], bodies: dict[str, str] | None = None +) -> _FakeSkillsMarketplaceSession: + session = _FakeSkillsMarketplaceSession(descriptions, bodies) + monkeypatch.setattr("huggingface_hub.cli._skills.get_session", lambda: session) + return session class TestSkillsMarketplaceCLI: def test_add_defaults_to_hf_cli(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"hf-cli": "HF CLI skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + _patch_skills_marketplace(monkeypatch, {"hf-cli": "HF CLI skill"}) result = runner.invoke(app, ["skills", "add", "--dest", str(tmp_path / "managed-skills")]) @@ -2967,36 +3033,63 @@ def test_add_defaults_to_hf_cli(self, runner: CliRunner, tmp_path: Path, monkeyp assert "Installed 'hf-cli'" in result.stdout def test_add_named_skill_to_dest(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"hf-cli": "HF CLI skill", "gradio": "Gradio skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + _patch_skills_marketplace(monkeypatch, {"hf-cli": "HF CLI skill", "huggingface-gradio": "Gradio skill"}) - result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(tmp_path / "managed-skills")]) + result = runner.invoke( + app, + ["skills", "add", "huggingface-gradio", "--dest", str(tmp_path / "managed-skills")], + ) assert result.exit_code == 0, result.output - skill_dir = tmp_path / "managed-skills" / "gradio" + skill_dir = tmp_path / "managed-skills" / "huggingface-gradio" assert skill_dir.joinpath("SKILL.md").exists() assert skill_dir.joinpath("notes.txt").exists() - assert "Installed 'gradio'" in result.stdout + assert "Installed 'huggingface-gradio'" in result.stdout + + def test_add_and_upgrade_use_marketplace_name_when_source_path_differs( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + session = _FakeSkillsMarketplaceSession({}) + session.add_skill( + "gradio", + description="Gradio skill", + body="v1", + source_path="skills/huggingface-gradio", + ) + monkeypatch.setattr("huggingface_hub.cli._skills.get_session", lambda: session) + dest = tmp_path / "managed-skills" + + add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + + assert add_result.exit_code == 0, add_result.output + assert (dest / "gradio" / "SKILL.md").exists() + assert not (dest / "huggingface-gradio").exists() + + session.add_revision("gradio", body="v2") + + upgrade_result = runner.invoke(app, ["skills", "upgrade", "gradio", "--dest", str(dest)]) + + assert upgrade_result.exit_code == 0, upgrade_result.output + assert "gradio: updated" in upgrade_result.stdout + assert "v2" in (dest / "gradio" / "SKILL.md").read_text(encoding="utf-8") def test_add_named_skill_for_assistant_creates_symlink( self, runner: CliRunner, tmp_path: Path, monkeypatch ) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["skills", "add", "gradio", "--claude"]) + result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--claude"]) assert result.exit_code == 0, result.output - central = tmp_path / ".agents" / "skills" / "gradio" - link = tmp_path / ".claude" / "skills" / "gradio" + central = tmp_path / ".agents" / "skills" / "huggingface-gradio" + link = tmp_path / ".claude" / "skills" / "huggingface-gradio" assert central.joinpath("SKILL.md").exists() assert link.is_symlink() assert link.resolve() == central.resolve() def test_add_unknown_skill_fails_cleanly(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"hf-cli": "HF CLI skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + _patch_skills_marketplace(monkeypatch, {"hf-cli": "HF CLI skill"}) result = runner.invoke(app, ["skills", "add", "missing", "--dest", str(tmp_path / "managed-skills")]) @@ -3006,12 +3099,11 @@ def test_add_unknown_skill_fails_cleanly(self, runner: CliRunner, tmp_path: Path def test_add_existing_skill_without_force_reports_hint( self, runner: CliRunner, tmp_path: Path, monkeypatch ) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) dest = tmp_path / "managed-skills" - first = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) - second = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + first = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) + second = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert first.exit_code == 0, first.output assert second.exit_code == 1 @@ -3019,136 +3111,141 @@ def test_add_existing_skill_without_force_reports_hint( assert "Re-run with --force to overwrite." in second.output def test_add_force_overwrites_existing_skill(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "remote version"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + _patch_skills_marketplace( + monkeypatch, + {"huggingface-gradio": "Gradio skill"}, + {"huggingface-gradio": "remote version"}, + ) dest = tmp_path / "managed-skills" - first = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + first = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert first.exit_code == 0, first.output - skill_file = dest / "gradio" / "SKILL.md" - skill_file.write_text("---\nname: gradio\ndescription: local\n---\n\nlocal override\n", encoding="utf-8") + skill_file = dest / "huggingface-gradio" / "SKILL.md" + skill_file.write_text( + "---\nname: huggingface-gradio\ndescription: local\n---\n\nlocal override\n", + encoding="utf-8", + ) - forced = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest), "--force"]) + forced = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest), "--force"]) assert forced.exit_code == 0, forced.output assert "remote version" in skill_file.read_text(encoding="utf-8") assert "local override" not in skill_file.read_text(encoding="utf-8") - def test_add_writes_source_metadata(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + def test_add_writes_skill_manifest(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + session = _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) dest = tmp_path / "managed-skills" - result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert result.exit_code == 0, result.output - payload = json.loads((dest / "gradio" / ".skill-source.json").read_text(encoding="utf-8")) - assert payload["repo_url"] == str(repo) - assert payload["repo_path"] == "skills/gradio" - assert payload["installed_revision"] == _git(repo, "rev-parse", "HEAD") - - def test_update_reports_up_to_date(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + payload = json.loads((dest / "huggingface-gradio" / ".hf-skill-manifest.json").read_text(encoding="utf-8")) + assert set(payload) == {"content_fingerprint", "installed_revision", "schema_version"} + assert payload["content_fingerprint"].startswith("sha256:") + assert payload["installed_revision"] == session.latest_revision("huggingface-gradio") + assert payload["schema_version"] == 1 + + def test_upgrade_reports_up_to_date(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) dest = tmp_path / "managed-skills" - add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert add_result.exit_code == 0, add_result.output - result = runner.invoke(app, ["skills", "update", "--dest", str(dest)]) + result = runner.invoke(app, ["skills", "upgrade", "--dest", str(dest)]) assert result.exit_code == 0, result.output - assert "gradio: up_to_date" in result.stdout + assert "huggingface-gradio: up_to_date" in result.stdout - def test_update_detects_newer_revision_and_refreshes_files( + def test_upgrade_detects_newer_revision_and_refreshes_files( self, runner: CliRunner, tmp_path: Path, monkeypatch ) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + session = _patch_skills_marketplace( + monkeypatch, + {"huggingface-gradio": "Gradio skill"}, + {"huggingface-gradio": "v1"}, + ) dest = tmp_path / "managed-skills" - add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert add_result.exit_code == 0, add_result.output - (repo / "skills" / "gradio" / "SKILL.md").write_text( - "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", - encoding="utf-8", - ) - _commit_all(repo, "update gradio") + session.add_revision("huggingface-gradio", body="v2") - result = runner.invoke(app, ["skills", "update", "gradio", "--dest", str(dest)]) + result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) assert result.exit_code == 0, result.output - assert "gradio: updated" in result.stdout - assert "v2" in (dest / "gradio" / "SKILL.md").read_text(encoding="utf-8") + assert "huggingface-gradio: updated" in result.stdout + assert "v2" in (dest / "huggingface-gradio" / "SKILL.md").read_text(encoding="utf-8") - def test_update_skips_dirty_install_without_force(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + def test_upgrade_skips_dirty_install_without_force(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + session = _patch_skills_marketplace( + monkeypatch, + {"huggingface-gradio": "Gradio skill"}, + {"huggingface-gradio": "v1"}, + ) dest = tmp_path / "managed-skills" - add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) + add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert add_result.exit_code == 0, add_result.output - skill_file = dest / "gradio" / "SKILL.md" + skill_file = dest / "huggingface-gradio" / "SKILL.md" skill_file.write_text(skill_file.read_text(encoding="utf-8") + "\nlocal edit\n", encoding="utf-8") - (repo / "skills" / "gradio" / "SKILL.md").write_text( - "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", - encoding="utf-8", - ) - _commit_all(repo, "update gradio") + session.add_revision("huggingface-gradio", body="v2") - result = runner.invoke(app, ["skills", "update", "gradio", "--dest", str(dest)]) + result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) assert result.exit_code == 0, result.output - assert "gradio: dirty (local modifications detected)" in result.stdout + assert "huggingface-gradio: dirty (local modifications detected)" in result.stdout assert "local edit" in skill_file.read_text(encoding="utf-8") - def test_update_scans_multiple_roots_by_default(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + def test_upgrade_updates_central_install_created_for_claude( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + session = _patch_skills_marketplace( + monkeypatch, + {"huggingface-gradio": "Gradio skill"}, + {"huggingface-gradio": "v1"}, + ) monkeypatch.chdir(tmp_path) - add_result = runner.invoke(app, ["skills", "add", "gradio", "--claude"]) + add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--claude"]) assert add_result.exit_code == 0, add_result.output - (repo / "skills" / "gradio" / "SKILL.md").write_text( - "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", - encoding="utf-8", - ) - _commit_all(repo, "update gradio") + session.add_revision("huggingface-gradio", body="v2") - result = runner.invoke(app, ["skills", "update"]) + result = runner.invoke(app, ["skills", "upgrade"]) assert result.exit_code == 0, result.output - assert "gradio: updated" in result.stdout - assert "v2" in (tmp_path / ".agents" / "skills" / "gradio" / "SKILL.md").read_text(encoding="utf-8") + assert "huggingface-gradio: updated" in result.stdout + assert "v2" in (tmp_path / ".agents" / "skills" / "huggingface-gradio" / "SKILL.md").read_text( + encoding="utf-8" + ) - def test_update_for_assistant_symlink_updates_central_target( + def test_upgrade_for_claude_symlink_updates_central_target( self, runner: CliRunner, tmp_path: Path, monkeypatch ) -> None: - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}, {"gradio": "v1"}) - monkeypatch.setattr("huggingface_hub.cli._skills.DEFAULT_SKILLS_REPO", str(repo)) + session = _patch_skills_marketplace( + monkeypatch, + {"huggingface-gradio": "Gradio skill"}, + {"huggingface-gradio": "v1"}, + ) monkeypatch.chdir(tmp_path) - add_result = runner.invoke(app, ["skills", "add", "gradio", "--claude"]) + add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--claude"]) assert add_result.exit_code == 0, add_result.output - link = tmp_path / ".claude" / "skills" / "gradio" - central = tmp_path / ".agents" / "skills" / "gradio" - (repo / "skills" / "gradio" / "SKILL.md").write_text( - "---\nname: gradio\ndescription: Gradio skill\n---\n\nv2\n", - encoding="utf-8", - ) - _commit_all(repo, "update gradio") + link = tmp_path / ".claude" / "skills" / "huggingface-gradio" + central = tmp_path / ".agents" / "skills" / "huggingface-gradio" + session.add_revision("huggingface-gradio", body="v2") - result = runner.invoke(app, ["skills", "update", "--claude"]) + result = runner.invoke(app, ["skills", "upgrade", "--claude"]) assert result.exit_code == 0, result.output - assert "gradio: updated" in result.stdout + assert "huggingface-gradio: updated" in result.stdout assert link.is_symlink() assert link.resolve() == central.resolve() assert "v2" in central.joinpath("SKILL.md").read_text(encoding="utf-8") - def test_force_reinstall_preserves_previous_install_on_staging_failure(self, tmp_path: Path) -> None: + def test_force_reinstall_preserves_previous_install_on_staging_failure(self, tmp_path: Path, monkeypatch) -> None: from huggingface_hub.cli import _skills - repo = _create_skills_repo(tmp_path, {"gradio": "Gradio skill"}) - skill = _skills.get_marketplace_skill("gradio", repo_url=str(repo)) + _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) + skill = _skills.get_marketplace_skill("huggingface-gradio") destination_root = tmp_path / "managed-skills" install_dir = _skills.install_marketplace_skill(skill, destination_root) original_text = install_dir.joinpath("SKILL.md").read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..9431a635b1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" From 8c1095dd60588bd0ce22062f351fd5d0fb6bfcfa Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 27 Mar 2026 11:23:10 +0100 Subject: [PATCH 12/18] Simplify a bit further --- docs/source/en/package_reference/cli.md | 3 +- src/huggingface_hub/cli/_skills.py | 47 ++----------------------- src/huggingface_hub/cli/skills.py | 11 ++---- tests/test_cli.py | 12 ++++--- 4 files changed, 13 insertions(+), 60 deletions(-) diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index 3b154237a4..0341ac80bd 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3192,14 +3192,13 @@ $ hf skills upgrade [OPTIONS] [NAME] * `--claude`: Upgrade skills installed for Claude. * `-g, --global`: Use global skills directories instead of the current project. * `--dest PATH`: Upgrade skills in a custom skills directory. -* `--force`: Overwrite local modifications when upgrading a skill. * `--help`: Show this message and exit. Examples $ hf skills upgrade $ hf skills upgrade hf-cli $ hf skills upgrade huggingface-gradio --dest=~/my-skills - $ hf skills upgrade --claude --force + $ hf skills upgrade --claude Learn more Use `hf --help` for more information about a command. diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index 7fc8a9e975..1e174f8f10 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -import hashlib import io import json import shutil @@ -27,7 +26,6 @@ SKILL_MANIFEST_SCHEMA_VERSION = 1 SkillUpdateStatus = Literal[ - "dirty", "up_to_date", "update_available", "updated", @@ -47,7 +45,6 @@ class MarketplaceSkill: class InstalledSkillManifest: schema_version: int installed_revision: str - content_fingerprint: str @dataclass(frozen=True) @@ -132,33 +129,15 @@ def check_for_updates( def apply_updates( roots: list[Path], selector: str | None = None, - force: bool = False, ) -> list[SkillUpdateInfo]: - """Upgrade managed skills in place, skipping dirty installs unless forced.""" + """Upgrade managed skills in place when the upstream revision changes.""" updates = check_for_updates(roots, selector) results: list[SkillUpdateInfo] = [] for update in updates: - results.append(_apply_single_update(update, force=force)) + results.append(_apply_single_update(update)) return results -def compute_skill_content_fingerprint(skill_dir: Path) -> str: - """Hash installed skill contents while ignoring the local manifest.""" - digest = hashlib.sha256() - root = skill_dir.resolve() - manifest_path = root / SKILL_MANIFEST_FILENAME - - for path in sorted(root.rglob("*")): - if path == manifest_path or not path.is_file(): - continue - digest.update(path.relative_to(root).as_posix().encode("utf-8")) - digest.update(b"\0") - digest.update(path.read_bytes()) - digest.update(b"\0") - - return f"sha256:{digest.hexdigest()}" - - def read_installed_skill_manifest(skill_dir: Path) -> tuple[InstalledSkillManifest | None, str | None]: """Read local skill metadata written by `hf skills add`.""" manifest_path = skill_dir / SKILL_MANIFEST_FILENAME @@ -180,7 +159,6 @@ def write_installed_skill_manifest(skill_dir: Path, manifest: InstalledSkillMani payload = { "schema_version": manifest.schema_version, "installed_revision": manifest.installed_revision, - "content_fingerprint": manifest.content_fingerprint, } (skill_dir / SKILL_MANIFEST_FILENAME).write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", @@ -244,7 +222,6 @@ def _populate_install_dir(skill: MarketplaceSkill, install_dir: Path) -> None: InstalledSkillManifest( schema_version=SKILL_MANIFEST_SCHEMA_VERSION, installed_revision=installed_revision, - content_fingerprint=compute_skill_content_fingerprint(install_dir), ), ) @@ -370,17 +347,6 @@ def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceS current_revision=current_revision, ) - fingerprint = compute_skill_content_fingerprint(skill_dir) - if fingerprint != manifest.content_fingerprint: - return SkillUpdateInfo( - name=skill_dir.name, - skill_dir=skill_dir, - status="dirty", - detail="local modifications detected", - current_revision=current_revision, - available_revision=available_revision, - ) - if available_revision == current_revision: return SkillUpdateInfo( name=skill_dir.name, @@ -400,11 +366,9 @@ def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceS ) -def _apply_single_update(update: SkillUpdateInfo, *, force: bool) -> SkillUpdateInfo: +def _apply_single_update(update: SkillUpdateInfo) -> SkillUpdateInfo: if update.status in {"up_to_date", "unmanaged", "invalid_metadata", "source_unreachable"}: return update - if update.status == "dirty" and not force: - return update manifest, error = read_installed_skill_manifest(update.skill_dir) if manifest is None: @@ -472,17 +436,12 @@ def _parse_installed_skill_manifest(payload: dict[str, Any]) -> InstalledSkillMa raise ValueError(f"unsupported schema_version: {payload.get('schema_version')}") installed_revision = payload.get("installed_revision") - content_fingerprint = payload.get("content_fingerprint") - if not isinstance(installed_revision, str) or not installed_revision: raise ValueError("missing installed_revision") - if not isinstance(content_fingerprint, str) or not content_fingerprint: - raise ValueError("missing content_fingerprint") return InstalledSkillManifest( schema_version=SKILL_MANIFEST_SCHEMA_VERSION, installed_revision=installed_revision, - content_fingerprint=content_fingerprint, ) diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index 7821b8ed5e..991a2d9aff 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -379,7 +379,7 @@ def skills_add( "hf skills upgrade", "hf skills upgrade hf-cli", "hf skills upgrade huggingface-gradio --dest=~/my-skills", - "hf skills upgrade --claude --force", + "hf skills upgrade --claude", ], ) def skills_upgrade( @@ -402,13 +402,6 @@ def skills_upgrade( help="Upgrade skills in a custom skills directory.", ), ] = None, - force: Annotated[ - bool, - typer.Option( - "--force", - help="Overwrite local modifications when upgrading a skill.", - ), - ] = False, ) -> None: """Upgrade installed Hugging Face marketplace skills.""" try: @@ -418,7 +411,7 @@ def skills_upgrade( dest=dest, ) - results = _skills.apply_updates(roots, selector=name, force=force) + results = _skills.apply_updates(roots, selector=name) if not results: print("No installed skills found.") return diff --git a/tests/test_cli.py b/tests/test_cli.py index 63b5d9a35f..cbfaa67a64 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3140,8 +3140,7 @@ def test_add_writes_skill_manifest(self, runner: CliRunner, tmp_path: Path, monk assert result.exit_code == 0, result.output payload = json.loads((dest / "huggingface-gradio" / ".hf-skill-manifest.json").read_text(encoding="utf-8")) - assert set(payload) == {"content_fingerprint", "installed_revision", "schema_version"} - assert payload["content_fingerprint"].startswith("sha256:") + assert set(payload) == {"installed_revision", "schema_version"} assert payload["installed_revision"] == session.latest_revision("huggingface-gradio") assert payload["schema_version"] == 1 @@ -3176,7 +3175,9 @@ def test_upgrade_detects_newer_revision_and_refreshes_files( assert "huggingface-gradio: updated" in result.stdout assert "v2" in (dest / "huggingface-gradio" / "SKILL.md").read_text(encoding="utf-8") - def test_upgrade_skips_dirty_install_without_force(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: + def test_upgrade_overwrites_local_edits_when_revision_changes( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: session = _patch_skills_marketplace( monkeypatch, {"huggingface-gradio": "Gradio skill"}, @@ -3192,8 +3193,9 @@ def test_upgrade_skips_dirty_install_without_force(self, runner: CliRunner, tmp_ result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) assert result.exit_code == 0, result.output - assert "huggingface-gradio: dirty (local modifications detected)" in result.stdout - assert "local edit" in skill_file.read_text(encoding="utf-8") + assert "huggingface-gradio: updated" in result.stdout + assert "local edit" not in skill_file.read_text(encoding="utf-8") + assert "v2" in skill_file.read_text(encoding="utf-8") def test_upgrade_updates_central_install_created_for_claude( self, runner: CliRunner, tmp_path: Path, monkeypatch From 976f2aca7abc02aecac272110eb95fe31aeaf39a Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 27 Mar 2026 12:20:49 +0100 Subject: [PATCH 13/18] Fix skills upgrade status after successful install --- src/huggingface_hub/cli/_skills.py | 15 +++++++++++++-- tests/test_cli.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index 1e174f8f10..a98d5298fc 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -385,7 +385,6 @@ def _apply_single_update(update: SkillUpdateInfo) -> SkillUpdateInfo: try: skill = get_marketplace_skill(update.skill_dir.name) install_marketplace_skill(skill, update.skill_dir.parent, force=True) - refreshed = _evaluate_update(update.skill_dir, {skill.name.lower(): skill}) except Exception as exc: return SkillUpdateInfo( name=update.name, @@ -396,13 +395,25 @@ def _apply_single_update(update: SkillUpdateInfo) -> SkillUpdateInfo: available_revision=update.available_revision, ) + refreshed_manifest, manifest_error = read_installed_skill_manifest(update.skill_dir) + if refreshed_manifest is None: + detail = manifest_error or "missing skill manifest after upgrade" + return SkillUpdateInfo( + name=update.name, + skill_dir=update.skill_dir, + status="invalid_metadata", + detail=detail, + current_revision=update.current_revision, + available_revision=update.available_revision, + ) + return SkillUpdateInfo( name=update.name, skill_dir=update.skill_dir, status="updated", detail="updated", current_revision=update.current_revision, - available_revision=refreshed.current_revision, + available_revision=refreshed_manifest.installed_revision, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index cbfaa67a64..5741a88b4c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3175,6 +3175,31 @@ def test_upgrade_detects_newer_revision_and_refreshes_files( assert "huggingface-gradio: updated" in result.stdout assert "v2" in (dest / "huggingface-gradio" / "SKILL.md").read_text(encoding="utf-8") + def test_upgrade_reports_updated_even_if_followup_revision_lookup_would_fail( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ) -> None: + session = _patch_skills_marketplace( + monkeypatch, + {"huggingface-gradio": "Gradio skill"}, + {"huggingface-gradio": "v1"}, + ) + dest = tmp_path / "managed-skills" + add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) + assert add_result.exit_code == 0, add_result.output + + session.add_revision("huggingface-gradio", body="v2") + updated_revision = session.latest_revision("huggingface-gradio") + + with patch( + "huggingface_hub.cli._skills._resolve_available_revision", + side_effect=[updated_revision, updated_revision, RuntimeError("transient follow-up lookup failure")], + ): + result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) + + assert result.exit_code == 0, result.output + assert "huggingface-gradio: updated" in result.stdout + assert "v2" in (dest / "huggingface-gradio" / "SKILL.md").read_text(encoding="utf-8") + def test_upgrade_overwrites_local_edits_when_revision_changes( self, runner: CliRunner, tmp_path: Path, monkeypatch ) -> None: From f0c993d845eb9614c48bf51822cfa0bc3b06cc9d Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Thu, 2 Apr 2026 12:02:53 +0200 Subject: [PATCH 14/18] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucain Co-authored-by: célina --- docs/source/en/package_reference/cli.md | 2 +- src/huggingface_hub/cli/_skills.py | 141 ++++++------------------ src/huggingface_hub/cli/skills.py | 47 ++++---- tests/test_cli.py | 2 - 4 files changed, 56 insertions(+), 136 deletions(-) diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index 6fd6f5ae8d..f925090e4c 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3274,7 +3274,7 @@ Examples $ hf skills add huggingface-gradio --dest=~/my-skills $ hf skills add --global $ hf skills add --claude - $ hf skills add --claude --global + $ hf skills add huggingface-gradio --claude --global Learn more Use `hf --help` for more information about a command. diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index a98d5298fc..ac83f8aa99 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -1,7 +1,4 @@ """Internal helpers for Hugging Face marketplace skill installation and upgrades.""" - -from __future__ import annotations - import base64 import io import json @@ -17,8 +14,7 @@ DEFAULT_SKILLS_REPO_ID = "huggingface/skills" -DEFAULT_SKILLS_REPO_OWNER = "huggingface" -DEFAULT_SKILLS_REPO_NAME = "skills" +DEFAULT_SKILLS_REPO_OWNER, DEFAULT_SKILLS_REPO_NAME = DEFAULT_SKILLS_REPO_ID.split("/") DEFAULT_SKILLS_REF = "main" MARKETPLACE_PATH = ".claude-plugin/marketplace.json" GITHUB_API_TIMEOUT = 10 @@ -230,11 +226,10 @@ def _validate_installed_skill_dir(skill_dir: Path) -> None: skill_file = skill_dir / "SKILL.md" if not skill_file.is_file(): raise RuntimeError(f"Installed skill is missing SKILL.md: {skill_file}") - skill_file.read_text(encoding="utf-8") def _extract_remote_github_path(revision: str, source_path: str, install_dir: Path) -> None: - tar_bytes = _github_api_get_bytes(f"tarball/{revision}") + tar_bytes = _github_api_get(f"tarball/{revision}").content _extract_tar_subpath(tar_bytes, source_path=source_path, install_dir=install_dir) @@ -316,20 +311,16 @@ def _iter_unique_skill_dirs(roots: list[Path]) -> list[Path]: def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceSkill]) -> SkillUpdateInfo: + base = SkillUpdateInfo(name=skill_dir.name, skill_dir=skill_dir, status="unmanaged") + manifest, error = read_installed_skill_manifest(skill_dir) if manifest is None: - return SkillUpdateInfo( - name=skill_dir.name, - skill_dir=skill_dir, - status="invalid_metadata" if error is not None else "unmanaged", - detail=error, - ) + return replace(base, status="invalid_metadata" if error else "unmanaged", detail=error) skill = marketplace_skills.get(skill_dir.name.lower()) if skill is None: - return SkillUpdateInfo( - name=skill_dir.name, - skill_dir=skill_dir, + return replace( + base, status="source_unreachable", detail=f"Skill '{skill_dir.name}' is no longer available in {DEFAULT_SKILLS_REPO_ID}.", current_revision=manifest.installed_revision, @@ -339,82 +330,29 @@ def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceS try: available_revision = _resolve_available_revision(skill) except Exception as exc: - return SkillUpdateInfo( - name=skill_dir.name, - skill_dir=skill_dir, - status="source_unreachable", - detail=str(exc), - current_revision=current_revision, - ) + return replace(base, status="source_unreachable", detail=str(exc), current_revision=current_revision) - if available_revision == current_revision: - return SkillUpdateInfo( - name=skill_dir.name, - skill_dir=skill_dir, - status="up_to_date", - current_revision=current_revision, - available_revision=available_revision, - ) - - return SkillUpdateInfo( - name=skill_dir.name, - skill_dir=skill_dir, - status="update_available", - detail="update available", + status = "up_to_date" if available_revision == current_revision else "update_available" + return replace( + base, + status=status, + detail="update available" if status == "update_available" else None, current_revision=current_revision, available_revision=available_revision, ) def _apply_single_update(update: SkillUpdateInfo) -> SkillUpdateInfo: - if update.status in {"up_to_date", "unmanaged", "invalid_metadata", "source_unreachable"}: - return update - - manifest, error = read_installed_skill_manifest(update.skill_dir) - if manifest is None: - detail = error or "missing skill manifest" - return SkillUpdateInfo( - name=update.name, - skill_dir=update.skill_dir, - status="invalid_metadata", - detail=detail, - current_revision=update.current_revision, - available_revision=update.available_revision, - ) + if update.status != "update_available": + return update try: skill = get_marketplace_skill(update.skill_dir.name) install_marketplace_skill(skill, update.skill_dir.parent, force=True) except Exception as exc: - return SkillUpdateInfo( - name=update.name, - skill_dir=update.skill_dir, - status="source_unreachable", - detail=str(exc), - current_revision=update.current_revision, - available_revision=update.available_revision, - ) + return replace(update, status="source_unreachable", detail=str(exc)) - refreshed_manifest, manifest_error = read_installed_skill_manifest(update.skill_dir) - if refreshed_manifest is None: - detail = manifest_error or "missing skill manifest after upgrade" - return SkillUpdateInfo( - name=update.name, - skill_dir=update.skill_dir, - status="invalid_metadata", - detail=detail, - current_revision=update.current_revision, - available_revision=update.available_revision, - ) - - return SkillUpdateInfo( - name=update.name, - skill_dir=update.skill_dir, - status="updated", - detail="updated", - current_revision=update.current_revision, - available_revision=refreshed_manifest.installed_revision, - ) + return replace(update, status="updated", detail="updated") def _filter_updates(updates: list[SkillUpdateInfo], selector: str | None) -> list[SkillUpdateInfo]: @@ -456,29 +394,22 @@ def _parse_installed_skill_manifest(payload: dict[str, Any]) -> InstalledSkillMa ) -def _github_api_get_json(endpoint: str, params: dict[str, Any] | None = None) -> Any: - response = _github_api_get(endpoint, params=params) - try: - return response.json() - except Exception as exc: # noqa: BLE001 - raise CLIError(f"Failed to decode GitHub API response for '{endpoint}': {exc}") from exc - - -def _github_api_get_bytes(endpoint: str, params: dict[str, Any] | None = None) -> bytes: - return _github_api_get(endpoint, params=params).content - - -def _github_api_get(endpoint: str, params: dict[str, Any] | None = None): - url = f"https://api.github.com/repos/{DEFAULT_SKILLS_REPO_OWNER}/{DEFAULT_SKILLS_REPO_NAME}/{endpoint.lstrip('/')}" - try: - response = get_session().get( - url, - params=params, - headers={"Accept": "application/vnd.github+json"}, - follow_redirects=True, - timeout=GITHUB_API_TIMEOUT, - ) - response.raise_for_status() - except Exception as exc: # noqa: BLE001 - raise CLIError(f"Failed to fetch '{endpoint}' from {DEFAULT_SKILLS_REPO_ID}: {exc}") from exc - return response +def _fetch_from_skills_repo(endpoint: str, params: dict[str, Any] | None = None, *, as_json: bool = False) -> Any: + url = f"https://api.github.com/repos/{DEFAULT_SKILLS_REPO_OWNER}/{DEFAULT_SKILLS_REPO_NAME}/{endpoint.lstrip('/')}" + try: + response = get_session().get( + url, + params=params, + headers={"Accept": "application/vnd.github+json"}, + follow_redirects=True, + timeout=GITHUB_API_TIMEOUT, + ) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to fetch '{endpoint}' from {DEFAULT_SKILLS_REPO_ID}: {exc}") from exc + if as_json: + try: + return response.json() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to decode GitHub API response for '{endpoint}': {exc}") from exc + return response.content diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index e142b33963..e007faec23 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -323,7 +323,7 @@ def _resolve_update_roots( *, claude: bool, global_: bool, - dest: Optional[Path], + dest: Path | None, ) -> list[Path]: if dest is not None: if claude or global_: @@ -385,27 +385,23 @@ def skills_add( Default location is in the current directory (.agents/skills) or user-level (~/.agents/skills). If `--claude` is specified, the skill is also symlinked into Claude's legacy skills directory. """ - try: - if dest: - if claude or global_: - print("--dest cannot be combined with --claude or --global.") - raise typer.Exit(code=1) - skill_dest = _install_to(dest, name, force) - print(f"Installed '{name}' to {skill_dest}") - return + if dest: + if claude or global_: + print("--dest cannot be combined with --claude or --global.") + raise typer.Exit(code=1) + skill_dest = _install_to(dest, name, force) + print(f"Installed '{name}' to {skill_dest}") + return - # Install to central location - central_path = CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL - central_skill_path = _install_to(central_path, name, force) - print(f"Installed '{name}' to central location: {central_skill_path}") + # Install to central location + central_path = CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL + central_skill_path = _install_to(central_path, name, force) + print(f"Installed '{name}' to central location: {central_skill_path}") - if claude: - agent_target = CLAUDE_GLOBAL if global_ else CLAUDE_LOCAL - link_path = _create_symlink(agent_target, name, central_skill_path, force) - print(f"Created symlink: {link_path}") - except CLIError as exc: - print(str(exc)) - raise typer.Exit(code=1) from exc + if claude: + agent_target = CLAUDE_GLOBAL if global_ else CLAUDE_LOCAL + link_path = _create_symlink(agent_target, name, central_skill_path, force) + print(f"Created symlink: {link_path}") @skills_cli.command( @@ -419,7 +415,7 @@ def skills_add( ) def skills_upgrade( name: Annotated[ - Optional[str], + str | None, typer.Argument(help="Optional installed skill name to upgrade.", show_default=False), ] = None, claude: Annotated[bool, typer.Option("--claude", help="Upgrade skills installed for Claude.")] = False, @@ -432,19 +428,14 @@ def skills_upgrade( ), ] = False, dest: Annotated[ - Optional[Path], + Path | None, typer.Option( help="Upgrade skills in a custom skills directory.", ), ] = None, ) -> None: """Upgrade installed Hugging Face marketplace skills.""" - try: - roots = _resolve_update_roots( - claude=claude, - global_=global_, - dest=dest, - ) + roots = _resolve_update_roots(claude=claude, global_=global_, dest=dest) results = _skills.apply_updates(roots, selector=name) if not results: diff --git a/tests/test_cli.py b/tests/test_cli.py index 981a2f368e..ed6361f85d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import base64 import io import json From 89c5d7820c6f2dd462d174f55405feaca1cb2882 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Thu, 2 Apr 2026 12:03:12 +0200 Subject: [PATCH 15/18] Delete uv.lock --- uv.lock | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 uv.lock diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 9431a635b1..0000000000 --- a/uv.lock +++ /dev/null @@ -1,3 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" From ef540afdd64dc0592b26784250fb0ef7855d9d43 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Thu, 2 Apr 2026 12:41:34 +0200 Subject: [PATCH 16/18] simplify --- docs/source/en/package_reference/cli.md | 2 +- src/huggingface_hub/cli/_skills.py | 54 ++-- src/huggingface_hub/cli/skills.py | 32 +- tests/test_cli.py | 389 ++---------------------- 4 files changed, 62 insertions(+), 415 deletions(-) diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index f925090e4c..6fd6f5ae8d 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3274,7 +3274,7 @@ Examples $ hf skills add huggingface-gradio --dest=~/my-skills $ hf skills add --global $ hf skills add --claude - $ hf skills add huggingface-gradio --claude --global + $ hf skills add --claude --global Learn more Use `hf --help` for more information about a command. diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index ac83f8aa99..b50f29ff37 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -1,11 +1,12 @@ """Internal helpers for Hugging Face marketplace skill installation and upgrades.""" + import base64 import io import json import shutil import tarfile import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path, PurePosixPath from typing import Any, Literal @@ -163,10 +164,14 @@ def write_installed_skill_manifest(skill_dir: Path, manifest: InstalledSkillMani def _load_marketplace_payload() -> dict[str, Any]: - payload = _github_api_get_json( + response = _fetch_from_skills_repo( f"contents/{MARKETPLACE_PATH}", params={"ref": DEFAULT_SKILLS_REF}, ) + try: + payload = response.json() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to decode GitHub API response for 'contents/{MARKETPLACE_PATH}': {exc}") from exc if not isinstance(payload, dict): raise CLIError("Invalid marketplace response: expected a JSON object.") @@ -229,7 +234,7 @@ def _validate_installed_skill_dir(skill_dir: Path) -> None: def _extract_remote_github_path(revision: str, source_path: str, install_dir: Path) -> None: - tar_bytes = _github_api_get(f"tarball/{revision}").content + tar_bytes = _fetch_from_skills_repo(f"tarball/{revision}").content _extract_tar_subpath(tar_bytes, source_path=source_path, install_dir=install_dir) @@ -343,8 +348,8 @@ def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceS def _apply_single_update(update: SkillUpdateInfo) -> SkillUpdateInfo: - if update.status != "update_available": - return update + if update.status != "update_available": + return update try: skill = get_marketplace_skill(update.skill_dir.name) @@ -363,10 +368,14 @@ def _filter_updates(updates: list[SkillUpdateInfo], selector: str | None) -> lis def _resolve_available_revision(skill: MarketplaceSkill) -> str: - payload = _github_api_get_json( + response = _fetch_from_skills_repo( "commits", params={"sha": DEFAULT_SKILLS_REF, "path": skill.repo_path, "per_page": 1}, ) + try: + payload = response.json() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to decode GitHub API response for 'commits': {exc}") from exc if not isinstance(payload, list) or not payload: raise CLIError(f"Unable to resolve the current revision for skill '{skill.name}'.") @@ -394,22 +403,17 @@ def _parse_installed_skill_manifest(payload: dict[str, Any]) -> InstalledSkillMa ) -def _fetch_from_skills_repo(endpoint: str, params: dict[str, Any] | None = None, *, as_json: bool = False) -> Any: - url = f"https://api.github.com/repos/{DEFAULT_SKILLS_REPO_OWNER}/{DEFAULT_SKILLS_REPO_NAME}/{endpoint.lstrip('/')}" - try: - response = get_session().get( - url, - params=params, - headers={"Accept": "application/vnd.github+json"}, - follow_redirects=True, - timeout=GITHUB_API_TIMEOUT, - ) - response.raise_for_status() - except Exception as exc: # noqa: BLE001 - raise CLIError(f"Failed to fetch '{endpoint}' from {DEFAULT_SKILLS_REPO_ID}: {exc}") from exc - if as_json: - try: - return response.json() - except Exception as exc: # noqa: BLE001 - raise CLIError(f"Failed to decode GitHub API response for '{endpoint}': {exc}") from exc - return response.content +def _fetch_from_skills_repo(endpoint: str, params: dict[str, Any] | None = None) -> Any: + url = f"https://api.github.com/repos/{DEFAULT_SKILLS_REPO_OWNER}/{DEFAULT_SKILLS_REPO_NAME}/{endpoint.lstrip('/')}" + try: + response = get_session().get( + url, + params=params, + headers={"Accept": "application/vnd.github+json"}, + follow_redirects=True, + timeout=GITHUB_API_TIMEOUT, + ) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise CLIError(f"Failed to fetch '{endpoint}' from {DEFAULT_SKILLS_REPO_ID}: {exc}") from exc + return response diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index e007faec23..073d97820b 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -291,7 +291,7 @@ def _remove_existing(path: Path, force: bool) -> None: if not (path.exists() or path.is_symlink()): return if not force: - raise SystemExit(f"Skill already exists at {path}.\nRe-run with --force to overwrite.") + raise CLIError(f"Skill already exists at {path}.\nRe-run with --force to overwrite.") if path.is_dir() and not path.is_symlink(): shutil.rmtree(path) else: @@ -304,7 +304,7 @@ def _install_to(skills_dir: Path, skill_name: str, force: bool) -> Path: try: return _skills.install_marketplace_skill(skill, skills_dir, force=force) except FileExistsError as exc: - raise SystemExit(f"{exc}\nRe-run with --force to overwrite.") from exc + raise CLIError(f"{exc}\nRe-run with --force to overwrite.") from exc def _create_symlink(agent_skills_dir: Path, skill_name: str, central_skill_path: Path, force: bool) -> Path: @@ -385,10 +385,9 @@ def skills_add( Default location is in the current directory (.agents/skills) or user-level (~/.agents/skills). If `--claude` is specified, the skill is also symlinked into Claude's legacy skills directory. """ - if dest: + if dest is not None: if claude or global_: - print("--dest cannot be combined with --claude or --global.") - raise typer.Exit(code=1) + raise CLIError("--dest cannot be combined with --claude or --global.") skill_dest = _install_to(dest, name, force) print(f"Installed '{name}' to {skill_dest}") return @@ -435,16 +434,13 @@ def skills_upgrade( ] = None, ) -> None: """Upgrade installed Hugging Face marketplace skills.""" - roots = _resolve_update_roots(claude=claude, global_=global_, dest=dest) - - results = _skills.apply_updates(roots, selector=name) - if not results: - print("No installed skills found.") - return - - for result in results: - detail = f" ({result.detail})" if result.detail else "" - print(f"{result.name}: {result.status}{detail}") - except CLIError as exc: - print(str(exc)) - raise typer.Exit(code=1) from exc + roots = _resolve_update_roots(claude=claude, global_=global_, dest=dest) + + results = _skills.apply_updates(roots, selector=name) + if not results: + print("No installed skills found.") + return + + for result in results: + detail = f" ({result.detail})" if result.detail else "" + print(f"{result.name}: {result.status}{detail}") diff --git a/tests/test_cli.py b/tests/test_cli.py index ed6361f85d..2c8ba08283 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,13 +1,10 @@ -import base64 -import io import json import os -import tarfile import warnings from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace -from typing import Any, Generator, Optional +from typing import Generator, Optional from unittest.mock import Mock, patch import pytest @@ -3517,244 +3514,19 @@ def test_collect_leaf_commands_finds_deeply_nested(self) -> None: assert any("jobs uv run" in p for p in leaf_paths) -class _FakeGitHubResponse: - def __init__(self, *, json_data: Any | None = None, content: bytes = b"", status_code: int = 200) -> None: - self._json_data = json_data - self.content = content - self.status_code = status_code - - def json(self) -> Any: - return self._json_data - - def raise_for_status(self) -> None: - if self.status_code >= 400: - raise RuntimeError(f"HTTP {self.status_code}") - - -def _render_marketplace_skill(name: str, description: str, body: str) -> str: - return f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n" - - -def _create_marketplace_tarball(root_dir: str, files: dict[str, str]) -> bytes: - buffer = io.BytesIO() - with tarfile.open(fileobj=buffer, mode="w:gz") as archive: - for path, content in files.items(): - encoded = content.encode("utf-8") - info = tarfile.TarInfo(name=f"{root_dir}/{path}") - info.size = len(encoded) - archive.addfile(info, io.BytesIO(encoded)) - return buffer.getvalue() - - -class _FakeSkillsMarketplaceSession: - def __init__(self, descriptions: dict[str, str], bodies: dict[str, str] | None = None) -> None: - self._revision_counter = 1 - self._skills: dict[str, dict[str, Any]] = {} - bodies = bodies or {} - - for name, description in descriptions.items(): - self.add_skill(name, description=description, body=bodies.get(name, f"# {name}")) - - def add_skill(self, name: str, *, description: str, body: str, source_path: str | None = None) -> str: - self._skills[name] = { - "description": description, - "source_path": source_path or f"skills/{name}", - "revisions": [], - } - return self.add_revision(name, body=body) - - def add_revision(self, name: str, *, body: str) -> str: - revision = f"{self._revision_counter:040x}" - self._revision_counter += 1 - self._skills[name]["revisions"].append({"sha": revision, "body": body}) - return revision - - def latest_revision(self, name: str) -> str: - return self._skills[name]["revisions"][-1]["sha"] - - def get(self, url: str, **kwargs: Any) -> _FakeGitHubResponse: - prefix = "https://api.github.com/repos/huggingface/skills/" - assert url.startswith(prefix), url - endpoint = url[len(prefix) :] - params = kwargs.get("params") or {} - - if endpoint == "contents/.claude-plugin/marketplace.json": - payload = { - "plugins": [ - { - "name": name, - "source": f"./{skill['source_path']}", - "skills": "./", - "description": skill["description"], - } - for name, skill in sorted(self._skills.items()) - ] - } - encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") - return _FakeGitHubResponse(json_data={"encoding": "base64", "content": encoded}) - - if endpoint == "commits": - source_path = params["path"] - for skill in self._skills.values(): - if skill["source_path"] == source_path: - return _FakeGitHubResponse(json_data=[{"sha": skill["revisions"][-1]["sha"]}]) - return _FakeGitHubResponse(status_code=404, json_data={"message": "Not Found"}) - - if endpoint.startswith("tarball/"): - requested_revision = endpoint.split("/", 1)[1] - for name, skill in self._skills.items(): - for revision in skill["revisions"]: - if revision["sha"] != requested_revision: - continue - tarball = _create_marketplace_tarball( - f"skills-{requested_revision[:7]}", - { - f"{skill['source_path']}/SKILL.md": _render_marketplace_skill( - name, - skill["description"], - revision["body"], - ), - f"{skill['source_path']}/notes.txt": f"{name} helper file\n", - }, - ) - return _FakeGitHubResponse(content=tarball) - return _FakeGitHubResponse(status_code=404, json_data={"message": "Not Found"}) - - raise AssertionError(f"Unexpected GitHub API request: {url}") - - -def _patch_skills_marketplace( - monkeypatch, descriptions: dict[str, str], bodies: dict[str, str] | None = None -) -> _FakeSkillsMarketplaceSession: - session = _FakeSkillsMarketplaceSession(descriptions, bodies) - monkeypatch.setattr("huggingface_hub.cli._skills.get_session", lambda: session) - return session - - class TestSkillsMarketplaceCLI: - def test_add_defaults_to_hf_cli(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - _patch_skills_marketplace(monkeypatch, {"hf-cli": "HF CLI skill"}) - - result = runner.invoke(app, ["skills", "add", "--dest", str(tmp_path / "managed-skills")]) - - assert result.exit_code == 0, result.output - assert (tmp_path / "managed-skills" / "hf-cli" / "SKILL.md").exists() - assert "Installed 'hf-cli'" in result.stdout - - def test_add_named_skill_to_dest(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - _patch_skills_marketplace(monkeypatch, {"hf-cli": "HF CLI skill", "huggingface-gradio": "Gradio skill"}) - - result = runner.invoke( - app, - ["skills", "add", "huggingface-gradio", "--dest", str(tmp_path / "managed-skills")], - ) - - assert result.exit_code == 0, result.output - skill_dir = tmp_path / "managed-skills" / "huggingface-gradio" - assert skill_dir.joinpath("SKILL.md").exists() - assert skill_dir.joinpath("notes.txt").exists() - assert "Installed 'huggingface-gradio'" in result.stdout - - def test_add_and_upgrade_use_marketplace_name_when_source_path_differs( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - session = _FakeSkillsMarketplaceSession({}) - session.add_skill( - "gradio", - description="Gradio skill", - body="v1", - source_path="skills/huggingface-gradio", - ) - monkeypatch.setattr("huggingface_hub.cli._skills.get_session", lambda: session) - dest = tmp_path / "managed-skills" - - add_result = runner.invoke(app, ["skills", "add", "gradio", "--dest", str(dest)]) - - assert add_result.exit_code == 0, add_result.output - assert (dest / "gradio" / "SKILL.md").exists() - assert not (dest / "huggingface-gradio").exists() - - session.add_revision("gradio", body="v2") - - upgrade_result = runner.invoke(app, ["skills", "upgrade", "gradio", "--dest", str(dest)]) - - assert upgrade_result.exit_code == 0, upgrade_result.output - assert "gradio: updated" in upgrade_result.stdout - assert "v2" in (dest / "gradio" / "SKILL.md").read_text(encoding="utf-8") - - def test_add_named_skill_for_assistant_creates_symlink( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) - monkeypatch.chdir(tmp_path) - - result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--claude"]) - - assert result.exit_code == 0, result.output - central = tmp_path / ".agents" / "skills" / "huggingface-gradio" - link = tmp_path / ".claude" / "skills" / "huggingface-gradio" - assert central.joinpath("SKILL.md").exists() - assert link.is_symlink() - assert link.resolve() == central.resolve() - - def test_add_unknown_skill_fails_cleanly(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - _patch_skills_marketplace(monkeypatch, {"hf-cli": "HF CLI skill"}) - - result = runner.invoke(app, ["skills", "add", "missing", "--dest", str(tmp_path / "managed-skills")]) - - assert result.exit_code == 1 - assert "Skill 'missing' not found in huggingface/skills" in result.output - - def test_add_existing_skill_without_force_reports_hint( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) - dest = tmp_path / "managed-skills" - - first = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) - second = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) - - assert first.exit_code == 0, first.output - assert second.exit_code == 1 - assert "Skill already exists:" in second.output - assert "Re-run with --force to overwrite." in second.output - - def test_add_force_overwrites_existing_skill(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - _patch_skills_marketplace( - monkeypatch, - {"huggingface-gradio": "Gradio skill"}, - {"huggingface-gradio": "remote version"}, - ) - dest = tmp_path / "managed-skills" - - first = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) - assert first.exit_code == 0, first.output - skill_file = dest / "huggingface-gradio" / "SKILL.md" - skill_file.write_text( - "---\nname: huggingface-gradio\ndescription: local\n---\n\nlocal override\n", - encoding="utf-8", - ) - - forced = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest), "--force"]) - - assert forced.exit_code == 0, forced.output - assert "remote version" in skill_file.read_text(encoding="utf-8") - assert "local override" not in skill_file.read_text(encoding="utf-8") - - def test_add_writes_skill_manifest(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - session = _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) + def test_add_installs_marketplace_skill_to_dest(self, runner: CliRunner, tmp_path: Path) -> None: dest = tmp_path / "managed-skills" result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert result.exit_code == 0, result.output - payload = json.loads((dest / "huggingface-gradio" / ".hf-skill-manifest.json").read_text(encoding="utf-8")) - assert set(payload) == {"installed_revision", "schema_version"} - assert payload["installed_revision"] == session.latest_revision("huggingface-gradio") - assert payload["schema_version"] == 1 + skill_dir = dest / "huggingface-gradio" + assert "Installed 'huggingface-gradio'" in result.stdout + assert skill_dir.joinpath("SKILL.md").is_file() + assert skill_dir.joinpath(".hf-skill-manifest.json").is_file() - def test_upgrade_reports_up_to_date(self, runner: CliRunner, tmp_path: Path, monkeypatch) -> None: - _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) + def test_upgrade_checks_remote_revision_for_installed_skill(self, runner: CliRunner, tmp_path: Path) -> None: dest = tmp_path / "managed-skills" add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) assert add_result.exit_code == 0, add_result.output @@ -3762,139 +3534,14 @@ def test_upgrade_reports_up_to_date(self, runner: CliRunner, tmp_path: Path, mon result = runner.invoke(app, ["skills", "upgrade", "--dest", str(dest)]) assert result.exit_code == 0, result.output - assert "huggingface-gradio: up_to_date" in result.stdout - - def test_upgrade_detects_newer_revision_and_refreshes_files( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - session = _patch_skills_marketplace( - monkeypatch, - {"huggingface-gradio": "Gradio skill"}, - {"huggingface-gradio": "v1"}, - ) - dest = tmp_path / "managed-skills" - add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) - assert add_result.exit_code == 0, add_result.output - - session.add_revision("huggingface-gradio", body="v2") - - result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) - - assert result.exit_code == 0, result.output - assert "huggingface-gradio: updated" in result.stdout - assert "v2" in (dest / "huggingface-gradio" / "SKILL.md").read_text(encoding="utf-8") - - def test_upgrade_reports_updated_even_if_followup_revision_lookup_would_fail( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - session = _patch_skills_marketplace( - monkeypatch, - {"huggingface-gradio": "Gradio skill"}, - {"huggingface-gradio": "v1"}, - ) - dest = tmp_path / "managed-skills" - add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) - assert add_result.exit_code == 0, add_result.output - - session.add_revision("huggingface-gradio", body="v2") - updated_revision = session.latest_revision("huggingface-gradio") - - with patch( - "huggingface_hub.cli._skills._resolve_available_revision", - side_effect=[updated_revision, updated_revision, RuntimeError("transient follow-up lookup failure")], - ): - result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) - - assert result.exit_code == 0, result.output - assert "huggingface-gradio: updated" in result.stdout - assert "v2" in (dest / "huggingface-gradio" / "SKILL.md").read_text(encoding="utf-8") - - def test_upgrade_overwrites_local_edits_when_revision_changes( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - session = _patch_skills_marketplace( - monkeypatch, - {"huggingface-gradio": "Gradio skill"}, - {"huggingface-gradio": "v1"}, - ) - dest = tmp_path / "managed-skills" - add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--dest", str(dest)]) - assert add_result.exit_code == 0, add_result.output - skill_file = dest / "huggingface-gradio" / "SKILL.md" - skill_file.write_text(skill_file.read_text(encoding="utf-8") + "\nlocal edit\n", encoding="utf-8") - session.add_revision("huggingface-gradio", body="v2") - - result = runner.invoke(app, ["skills", "upgrade", "huggingface-gradio", "--dest", str(dest)]) - - assert result.exit_code == 0, result.output - assert "huggingface-gradio: updated" in result.stdout - assert "local edit" not in skill_file.read_text(encoding="utf-8") - assert "v2" in skill_file.read_text(encoding="utf-8") - - def test_upgrade_updates_central_install_created_for_claude( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - session = _patch_skills_marketplace( - monkeypatch, - {"huggingface-gradio": "Gradio skill"}, - {"huggingface-gradio": "v1"}, - ) - monkeypatch.chdir(tmp_path) - - add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--claude"]) - assert add_result.exit_code == 0, add_result.output - session.add_revision("huggingface-gradio", body="v2") - - result = runner.invoke(app, ["skills", "upgrade"]) - - assert result.exit_code == 0, result.output - assert "huggingface-gradio: updated" in result.stdout - assert "v2" in (tmp_path / ".agents" / "skills" / "huggingface-gradio" / "SKILL.md").read_text( - encoding="utf-8" - ) - - def test_upgrade_for_claude_symlink_updates_central_target( - self, runner: CliRunner, tmp_path: Path, monkeypatch - ) -> None: - session = _patch_skills_marketplace( - monkeypatch, - {"huggingface-gradio": "Gradio skill"}, - {"huggingface-gradio": "v1"}, - ) - monkeypatch.chdir(tmp_path) - - add_result = runner.invoke(app, ["skills", "add", "huggingface-gradio", "--claude"]) - assert add_result.exit_code == 0, add_result.output - link = tmp_path / ".claude" / "skills" / "huggingface-gradio" - central = tmp_path / ".agents" / "skills" / "huggingface-gradio" - session.add_revision("huggingface-gradio", body="v2") - - result = runner.invoke(app, ["skills", "upgrade", "--claude"]) - - assert result.exit_code == 0, result.output - assert "huggingface-gradio: updated" in result.stdout - assert link.is_symlink() - assert link.resolve() == central.resolve() - assert "v2" in central.joinpath("SKILL.md").read_text(encoding="utf-8") - - def test_force_reinstall_preserves_previous_install_on_staging_failure(self, tmp_path: Path, monkeypatch) -> None: - from huggingface_hub.cli import _skills - - _patch_skills_marketplace(monkeypatch, {"huggingface-gradio": "Gradio skill"}) - skill = _skills.get_marketplace_skill("huggingface-gradio") - destination_root = tmp_path / "managed-skills" - install_dir = _skills.install_marketplace_skill(skill, destination_root) - original_text = install_dir.joinpath("SKILL.md").read_text(encoding="utf-8") - original_populate = _skills._populate_install_dir - - def fail_populate(*, skill, install_dir): - original_populate(skill=skill, install_dir=install_dir) - install_dir.joinpath("SKILL.md").unlink() - raise RuntimeError("simulated staging failure") - - with patch("huggingface_hub.cli._skills._populate_install_dir", side_effect=fail_populate): - with pytest.raises(RuntimeError): - _skills.install_marketplace_skill(skill, destination_root, force=True) - - assert install_dir.exists() - assert install_dir.joinpath("SKILL.md").read_text(encoding="utf-8") == original_text + skill_dir = dest / "huggingface-gradio" + assert skill_dir.joinpath("SKILL.md").is_file() + assert skill_dir.joinpath(".hf-skill-manifest.json").is_file() + # Live marketplace content can change between the add and upgrade calls. + assert any( + status in result.stdout + for status in ( + "huggingface-gradio: up_to_date", + "huggingface-gradio: updated", + ) + ), result.stdout From b5259995f5425e9328861b6599588dc8dca2a9d1 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Thu, 2 Apr 2026 13:02:07 +0200 Subject: [PATCH 17/18] fix: annotate skill update status literal --- src/huggingface_hub/cli/_skills.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/huggingface_hub/cli/_skills.py b/src/huggingface_hub/cli/_skills.py index b50f29ff37..ab10f80985 100644 --- a/src/huggingface_hub/cli/_skills.py +++ b/src/huggingface_hub/cli/_skills.py @@ -337,7 +337,7 @@ def _evaluate_update(skill_dir: Path, marketplace_skills: dict[str, MarketplaceS except Exception as exc: return replace(base, status="source_unreachable", detail=str(exc), current_revision=current_revision) - status = "up_to_date" if available_revision == current_revision else "update_available" + status: SkillUpdateStatus = "up_to_date" if available_revision == current_revision else "update_available" return replace( base, status=status, From db3dc8647ce82dde6efed19228cf0e395b19b54a Mon Sep 17 00:00:00 2001 From: Lucain Pouget Date: Thu, 2 Apr 2026 15:59:53 +0200 Subject: [PATCH 18/18] update example --- docs/source/en/package_reference/cli.md | 2 +- src/huggingface_hub/cli/skills.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/en/package_reference/cli.md b/docs/source/en/package_reference/cli.md index e36bd4d41f..6b7eb68479 100644 --- a/docs/source/en/package_reference/cli.md +++ b/docs/source/en/package_reference/cli.md @@ -3274,7 +3274,7 @@ Examples $ hf skills add huggingface-gradio --dest=~/my-skills $ hf skills add --global $ hf skills add --claude - $ hf skills add --claude --global + $ hf skills add huggingface-gradio --claude --global Learn more Use `hf --help` for more information about a command. diff --git a/src/huggingface_hub/cli/skills.py b/src/huggingface_hub/cli/skills.py index 073d97820b..2b09d467e1 100644 --- a/src/huggingface_hub/cli/skills.py +++ b/src/huggingface_hub/cli/skills.py @@ -349,7 +349,7 @@ def skills_preview() -> None: "hf skills add huggingface-gradio --dest=~/my-skills", "hf skills add --global", "hf skills add --claude", - "hf skills add --claude --global", + "hf skills add huggingface-gradio --claude --global", ], ) def skills_add(