Files
EvoScientist/skills/find-skills/scripts/install_skill.py
T
2026-01-29 17:39:50 +00:00

212 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Install a skill from GitHub into a local skills directory.
Self-contained installer — no external dependencies beyond git.
Usage examples:
# Install from a GitHub URL (auto-detects repo, ref, path)
python install_skill.py --url https://github.com/anthropics/skills/tree/main/excel
# Install from repo + path
python install_skill.py --repo anthropics/skills --path excel
# Install multiple skills from the same repo
python install_skill.py --repo anthropics/skills --path excel --path pdf
# Install with a specific git ref
python install_skill.py --repo org/repo --path my-skill --ref v2.0
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
def parse_github_url(url: str) -> tuple[str, str | None, str | None]:
"""Parse a GitHub URL into (repo, ref, path).
Supports formats:
https://github.com/owner/repo
https://github.com/owner/repo/tree/main/path/to/skill
github.com/owner/repo/tree/branch/path
owner/repo@skill-name (shorthand from skills.sh)
Returns:
(repo, ref_or_none, path_or_none)
"""
# Shorthand: owner/repo@path
if "@" in url and "://" not in url:
repo, path = url.split("@", 1)
return repo.strip(), None, path.strip()
# Strip protocol and github.com prefix
cleaned = re.sub(r"^https?://", "", url)
cleaned = re.sub(r"^github\.com/", "", cleaned)
cleaned = cleaned.rstrip("/")
# Match: owner/repo/tree/ref/path...
m = re.match(r"^([^/]+/[^/]+)/tree/([^/]+)(?:/(.+))?$", cleaned)
if m:
return m.group(1), m.group(2), m.group(3)
# Match: owner/repo (no tree)
m = re.match(r"^([^/]+/[^/]+)$", cleaned)
if m:
return m.group(1), None, None
raise ValueError(f"Cannot parse GitHub URL: {url}")
def clone_repo(repo: str, ref: str | None, dest: str) -> None:
"""Shallow-clone a GitHub repo."""
clone_url = f"https://github.com/{repo}.git"
cmd = ["git", "clone", "--depth", "1"]
if ref:
cmd += ["--branch", ref]
cmd += [clone_url, dest]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"git clone failed: {result.stderr.strip()}")
def copy_skill(src: str, dest_dir: str) -> str:
"""Copy a skill directory to the destination.
Returns:
The skill name (directory basename).
"""
skill_name = os.path.basename(src.rstrip("/"))
target = os.path.join(dest_dir, skill_name)
if os.path.exists(target):
shutil.rmtree(target)
print(f" Replaced existing: {skill_name}")
shutil.copytree(src, target)
return skill_name
def validate_skill(path: str) -> bool:
"""Check that a directory looks like a valid skill (has SKILL.md)."""
return os.path.isfile(os.path.join(path, "SKILL.md"))
def install(
repo: str,
paths: list[str],
ref: str | None,
dest: str,
) -> list[str]:
"""Install skill(s) from a GitHub repo.
Returns:
List of installed skill names.
"""
os.makedirs(dest, exist_ok=True)
installed: list[str] = []
with tempfile.TemporaryDirectory(prefix="skill-install-") as tmp:
clone_dir = os.path.join(tmp, "repo")
print(f"Cloning {repo}" + (f" @{ref}" if ref else "") + "...")
clone_repo(repo, ref, clone_dir)
if not paths:
# No path specified — treat entire repo as a single skill
if validate_skill(clone_dir):
name = copy_skill(clone_dir, dest)
installed.append(name)
else:
# List top-level directories that look like skills
for entry in sorted(os.listdir(clone_dir)):
entry_path = os.path.join(clone_dir, entry)
if os.path.isdir(entry_path) and validate_skill(entry_path):
name = copy_skill(entry_path, dest)
installed.append(name)
if not installed:
print("No valid skills found in repository root.", file=sys.stderr)
else:
for p in paths:
skill_path = os.path.join(clone_dir, p.strip("/"))
if not os.path.isdir(skill_path):
print(f" Path not found: {p}", file=sys.stderr)
continue
if not validate_skill(skill_path):
print(f" No SKILL.md in: {p}", file=sys.stderr)
continue
name = copy_skill(skill_path, dest)
installed.append(name)
return installed
def main() -> int:
parser = argparse.ArgumentParser(
description="Install skills from GitHub into a local skills directory.",
)
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument(
"--url",
help="GitHub URL (e.g. https://github.com/owner/repo/tree/main/skill-name)",
)
src.add_argument(
"--repo",
help="GitHub repo (e.g. owner/repo)",
)
parser.add_argument(
"--path",
action="append",
default=[],
help="Path to skill inside repo (repeatable)",
)
parser.add_argument(
"--ref",
default=None,
help="Git branch or tag (default: repo default branch)",
)
parser.add_argument(
"--dest",
default="./skills",
help="Destination directory (default: ./skills)",
)
args = parser.parse_args()
dest = args.dest
# Parse source
if args.url:
repo, ref, path = parse_github_url(args.url)
ref = args.ref or ref
paths = [path] if path else args.path
else:
repo = args.repo
ref = args.ref
paths = args.path
try:
installed = install(repo, paths, ref, dest)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if installed:
print(f"\nInstalled {len(installed)} skill(s) to {dest}/:")
for name in installed:
print(f" - {name}")
else:
print("No skills were installed.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())