You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

198 lines
6.5 KiB

1 month ago
# Copyright 2025 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Contains commands to manage skills for AI assistants.
Usage:
# install the hf-cli skill for Claude (project-level, in current directory)
hf skills add --claude
# install for multiple assistants (project-level)
hf skills add --claude --codex --opencode
# install globally (user-level)
hf skills add --claude --global
# install to a custom directory
hf skills add --dest=~/my-skills
# overwrite an existing skill
hf skills add --claude --force
"""
import os
import shutil
from pathlib import Path
from typing import Annotated, Optional
import typer
from huggingface_hub.errors import CLIError
from huggingface_hub.utils import get_session
from ._cli_utils import typer_factory
DEFAULT_SKILL_ID = "hf-cli"
_GITHUB_RAW_BASE = "https://raw.githubusercontent.com/huggingface/huggingface_hub/main/docs/source/en"
_SKILL_MD_URL = f"{_GITHUB_RAW_BASE}/guides/cli.md"
_REFERENCE_URL = f"{_GITHUB_RAW_BASE}/package_reference/cli.md"
_SKILL_YAML_PREFIX = """\
---
name: hf-cli
description: >
Hugging Face Hub CLI (`hf`) for downloading, uploading, and managing
repositories, models, datasets, and Spaces on the Hugging Face Hub.
---
The Hugging Face Hub CLI tool `hf` is available. IMPORTANT: The `hf` command replaces the deprecated `huggingface_cli` command.
Use `hf --help` to view available functions. Note that auth commands are now all under `hf auth` e.g. `hf auth whoami`.
"""
CENTRAL_LOCAL = Path(".agents/skills")
CENTRAL_GLOBAL = Path("~/.agents/skills")
GLOBAL_TARGETS = {
"codex": Path("~/.codex/skills"),
"claude": Path("~/.claude/skills"),
"opencode": Path("~/.config/opencode/skills"),
}
LOCAL_TARGETS = {
"codex": Path(".codex/skills"),
"claude": Path(".claude/skills"),
"opencode": Path(".opencode/skills"),
}
skills_cli = typer_factory(help="Manage skills for AI assistants.")
def _download(url: str) -> str:
"""Download text content from a URL."""
response = get_session().get(url)
response.raise_for_status()
return response.text
def _remove_existing(path: Path, force: bool) -> None:
"""Remove existing file/directory/symlink if force is True, otherwise raise an error."""
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.")
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
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()
# SKILL.md the main guide, prefixed with YAML metadata
skill_content = _download(_SKILL_MD_URL)
(dest / "SKILL.md").write_text(_SKILL_YAML_PREFIX + skill_content, encoding="utf-8")
# references/cli.md the full CLI reference
ref_dir = dest / "references"
ref_dir.mkdir()
ref_content = _download(_REFERENCE_URL)
(ref_dir / "cli.md").write_text(ref_content, encoding="utf-8")
return dest
def _create_symlink(agent_skills_dir: Path, 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
_remove_existing(link_path, force)
link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir))
return link_path
@skills_cli.command(
"add",
examples=[
"hf skills add --claude",
"hf skills add --claude --global",
"hf skills add --codex --opencode",
],
)
def skills_add(
claude: Annotated[bool, typer.Option("--claude", help="Install for Claude.")] = False,
codex: Annotated[bool, typer.Option("--codex", help="Install for Codex.")] = False,
opencode: Annotated[bool, typer.Option("--opencode", help="Install for OpenCode.")] = False,
global_: Annotated[
bool,
typer.Option(
"--global",
"-g",
help="Install globally (user-level) instead of in the current project directory.",
),
] = False,
dest: Annotated[
Optional[Path],
typer.Option(
help="Install into a custom destination (path to skills directory).",
),
] = None,
force: Annotated[
bool,
typer.Option(
"--force",
help="Overwrite existing skills in the destination.",
),
] = False,
) -> None:
"""Download a skill and install it for an AI assistant."""
if not (claude or codex or opencode or dest):
raise CLIError("Pick a destination via --claude, --codex, --opencode, or --dest.")
if dest:
if claude or codex or opencode or global_:
print("--dest cannot be combined with --claude, --codex, --opencode, or --global.")
raise typer.Exit(code=1)
skill_dest = _install_to(dest, force)
print(f"Installed '{DEFAULT_SKILL_ID}' to {skill_dest}")
return
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 opencode:
agent_targets.append(targets_dict["opencode"])
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}")
for agent_target in agent_targets:
link_path = _create_symlink(agent_target, central_skill_path, force)
print(f"Created symlink: {link_path}")