Files
hive/core/framework/skills/installer.py
T
Richard Tang 7b0b472167 chore: lint
2026-04-29 19:16:00 -07:00

363 lines
13 KiB
Python

"""Skill install, remove, and fork operations.
Handles filesystem operations for the hive skill CLI:
- install_from_git: git clone --depth=1 → copy to target directory
- install_from_registry: resolve registry entry → delegate to install_from_git
- remove_skill: delete a skill from ~/.hive/skills/
- fork_skill: copy a skill to a new location with a new name
- maybe_show_install_notice: one-time security notice on first install (NFR-5)
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
from framework.skills.parser import ParsedSkill
from framework.skills.skill_errors import SkillError, SkillErrorCode
# Default install destination for user-scope skills + sentinel file for
# the one-time security notice on first install (NFR-5). Computed via
# helpers so HIVE_HOME (set by the desktop shell to a per-user dir)
# is honoured. ``framework.config.HIVE_HOME`` is module-global and
# resolved at first import — so a single call here is enough; we don't
# need to re-resolve on every access.
def _user_skills_dir() -> Path:
from framework.config import HIVE_HOME
return HIVE_HOME / "skills"
def _install_notice_sentinel() -> Path:
from framework.config import HIVE_HOME
return HIVE_HOME / ".install_notice_shown"
_INSTALL_NOTICE = """\
─────────────────────────────────────────────────────────────
Security Notice: Installing Third-Party Skills
─────────────────────────────────────────────────────────────
Skills are instructions executed by AI agents. A malicious
skill can manipulate agent behavior, exfiltrate data, or
cause unintended actions.
Only install skills from sources you trust. Review the
SKILL.md before running it in a production environment.
This notice is shown once. Use 'hive skill doctor' to audit
installed skills at any time.
─────────────────────────────────────────────────────────────
"""
def maybe_show_install_notice() -> None:
"""Print a one-time security notice before the first skill install (NFR-5).
Touches a sentinel file in $HIVE_HOME after showing the notice so it is
only displayed once across all future installs.
"""
sentinel = _install_notice_sentinel()
if sentinel.exists():
return
print(_INSTALL_NOTICE, flush=True)
try:
sentinel.parent.mkdir(parents=True, exist_ok=True)
sentinel.touch()
except OSError:
pass # If we can't write the sentinel, just show the notice every time
def install_from_git(
git_url: str,
skill_name: str,
subdirectory: str | None = None,
version: str | None = None,
target_dir: Path | None = None,
) -> Path:
"""Install a skill from a git repository.
Clones the repository with --depth=1 into a temporary directory, then
copies the skill subdirectory (or repo root) to the target location.
Args:
git_url: Git repository URL to clone.
skill_name: Name of the skill — used as the install directory name.
subdirectory: Relative path within the repo to the skill directory.
If None, the repo root is treated as the skill directory.
version: Git ref to checkout (tag, branch, or commit). Defaults to
the remote's default branch.
target_dir: Where to install the skill. Defaults to
~/.hive/skills/<skill_name>/.
Returns:
Path to the installed skill directory (the parent of SKILL.md).
Raises:
SkillError: On any failure (git not found, clone failed, SKILL.md missing).
"""
if shutil.which("git") is None:
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Cannot install '{skill_name}' from {git_url}",
why="git is not installed or not on PATH.",
fix="Install git (https://git-scm.com/) and retry.",
)
dest = (target_dir or _user_skills_dir()) / skill_name
if dest.exists():
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Cannot install '{skill_name}'",
why=f"Directory already exists: {dest}",
fix=f"Run 'hive skill remove {skill_name}' first, or use a different --name.",
)
tmp_dir = tempfile.mkdtemp(prefix="hive-skill-install-")
try:
_git_clone_shallow(git_url, Path(tmp_dir), version=version)
# Locate the skill within the cloned repo
source_dir = Path(tmp_dir) / subdirectory if subdirectory else Path(tmp_dir)
skill_md = source_dir / "SKILL.md"
if not skill_md.exists():
raise SkillError(
code=SkillErrorCode.SKILL_NOT_FOUND,
what=f"No SKILL.md found in '{subdirectory or '/'}' of {git_url}",
why="The expected SKILL.md file is not present at the given path.",
fix=(
"Check the repository structure and use "
"'hive skill install --from <url>' with the correct subdirectory."
),
)
dest.parent.mkdir(parents=True, exist_ok=True)
_copy_skill_dir(source_dir, dest)
return dest
except SkillError:
raise
except Exception as exc:
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Failed to install '{skill_name}' from {git_url}",
why=str(exc),
fix="Check the URL, your network connection, and git configuration.",
) from exc
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def install_from_registry(
registry_entry: dict,
target_dir: Path | None = None,
version: str | None = None,
) -> Path:
"""Install a skill using a registry index entry.
Resolves the git_url and subdirectory from the registry entry and
delegates to install_from_git.
Args:
registry_entry: A skill entry dict from skill_index.json.
target_dir: Override install destination.
version: Override version (defaults to entry's 'version' field).
Returns:
Path to the installed skill directory.
Raises:
SkillError: If the registry entry is missing required fields or install fails.
"""
name = registry_entry.get("name")
git_url = registry_entry.get("git_url")
if not name or not git_url:
raise SkillError(
code=SkillErrorCode.SKILL_NOT_FOUND,
what="Incomplete registry entry — missing 'name' or 'git_url'.",
why="The registry index entry does not contain all required fields.",
fix="Report this issue to the registry maintainer.",
)
resolved_version = version or registry_entry.get("version")
subdirectory = registry_entry.get("subdirectory")
return install_from_git(
git_url=git_url,
skill_name=str(name),
subdirectory=subdirectory,
version=resolved_version,
target_dir=target_dir,
)
def remove_skill(name: str, skills_dir: Path | None = None) -> bool:
"""Remove an installed skill from the user skills directory.
Args:
name: Skill directory name to remove.
skills_dir: Override the search directory (default: ~/.hive/skills/).
Returns:
True if removed, False if not found.
Raises:
SkillError: If the directory exists but cannot be removed.
"""
target = (skills_dir or _user_skills_dir()) / name
if not target.exists():
return False
try:
shutil.rmtree(target)
return True
except OSError as exc:
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Failed to remove skill '{name}' at {target}",
why=str(exc),
fix="Check file permissions and try again.",
) from exc
def fork_skill(
source: ParsedSkill,
new_name: str,
target_dir: Path,
) -> Path:
"""Create a local editable copy of a skill with a new name.
Copies the skill's base directory to target_dir/new_name/ and rewrites
the 'name' field in the copied SKILL.md frontmatter.
Args:
source: The source skill to fork (from SkillDiscovery).
new_name: Name for the forked skill.
target_dir: Parent directory for the fork (e.g. ~/.hive/skills/).
Returns:
Path to the forked skill directory.
Raises:
SkillError: If the target already exists or the copy fails.
"""
dest = target_dir / new_name
if dest.exists():
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Cannot fork to '{dest}'",
why="Target directory already exists.",
fix=f"Choose a different --name or remove '{dest}' first.",
)
source_dir = Path(source.base_dir)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
_copy_skill_dir(source_dir, dest)
except OSError as exc:
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Failed to fork skill '{source.name}' to '{dest}'",
why=str(exc),
fix="Check file permissions and available disk space.",
) from exc
# Rewrite the name in the forked SKILL.md via YAML round-trip (safe)
forked_skill_md = dest / "SKILL.md"
if forked_skill_md.exists():
_rewrite_name_in_skill_md(forked_skill_md, new_name)
return dest
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _git_clone_shallow(git_url: str, target: Path, version: str | None = None) -> None:
"""Clone a git repo at --depth=1 into target directory.
Args:
git_url: Repository URL.
target: Destination directory (will be created by git).
version: Optional git ref (branch/tag) to clone.
Raises:
SkillError: If the clone fails.
"""
cmd = ["git", "clone", "--depth=1"]
if version:
cmd += ["--branch", version]
cmd += [git_url, str(target)]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"git clone timed out for {git_url}",
why="The clone operation took longer than 60 seconds.",
fix="Check your network connection and retry.",
) from None
except (FileNotFoundError, OSError) as exc:
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"Cannot run git for {git_url}",
why=str(exc),
fix="Ensure git is installed and on PATH.",
) from exc
if result.returncode != 0:
stderr = result.stderr.strip()
raise SkillError(
code=SkillErrorCode.SKILL_ACTIVATION_FAILED,
what=f"git clone failed for {git_url}",
why=stderr or f"git exited with code {result.returncode}",
fix="Check the URL is correct and the repository is publicly accessible.",
)
def _copy_skill_dir(src: Path, dst: Path) -> None:
"""Copy a skill directory, ignoring VCS and cache artifacts."""
ignore = shutil.ignore_patterns(".git", "__pycache__", "*.pyc", ".venv", "venv", "node_modules")
shutil.copytree(src, dst, ignore=ignore)
def _rewrite_name_in_skill_md(skill_md: Path, new_name: str) -> None:
"""Rewrite the 'name' field in a SKILL.md frontmatter via YAML round-trip.
Parses the frontmatter with yaml.safe_load, updates 'name', re-serializes
with yaml.dump, and reconstructs the file as:
---
<yaml>
---
<body>
Falls back to no-op if the file can't be parsed (the copy is still usable).
"""
import yaml
try:
content = skill_md.read_text(encoding="utf-8")
parts = content.split("---", 2)
if len(parts) < 3:
return
frontmatter = yaml.safe_load(parts[1].strip())
if not isinstance(frontmatter, dict):
return
frontmatter["name"] = new_name
new_yaml = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
new_content = f"---\n{new_yaml}---\n{parts[2]}"
skill_md.write_text(new_content, encoding="utf-8")
except Exception:
pass # Degraded: forked copy works, name just isn't updated