scripts/install-skill-from-github.py
scripts/install-skill-from-github.pyBrowse 8 files
2,757 tokens
11,917 bytes
Token encoding: o200k_base
Snapshot 18d7ace
← Back to SKILL.md
1#!/usr/bin/env python32"""Install a skill from a GitHub repo path into $CODEX_HOME/skills."""3 4from __future__ import annotations5 6import argparse7from dataclasses import dataclass8import os9import shutil10import subprocess11import sys12import tempfile13import urllib.error14import urllib.parse15import zipfile16 17from github_utils import github_request18 19DEFAULT_REF = "main"20 21 22@dataclass23class Args:24 url: str | None = None25 repo: str | None = None26 path: list[str] | None = None27 ref: str = DEFAULT_REF28 dest: str | None = None29 name: str | None = None30 method: str = "auto"31 32 33@dataclass34class Source:35 owner: str36 repo: str37 ref: str38 paths: list[str]39 repo_url: str | None = None40 41 42class InstallError(Exception):43 pass44 45 46def _codex_home() -> str:47 return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex"))48 49 50def _tmp_root() -> str:51 base = os.path.join(tempfile.gettempdir(), "codex")52 os.makedirs(base, exist_ok=True)53 return base54 55 56def _request(url: str) -> bytes:57 return github_request(url, "codex-skill-install")58 59 60def _parse_github_url(url: str, default_ref: str) -> tuple[str, str, str, str | None]:61 parsed = urllib.parse.urlparse(url)62 if parsed.netloc != "github.com":63 raise InstallError("Only GitHub URLs are supported for download mode.")64 parts = [p for p in parsed.path.split("/") if p]65 if len(parts) < 2:66 raise InstallError("Invalid GitHub URL.")67 owner, repo = parts[0], parts[1]68 ref = default_ref69 subpath = ""70 if len(parts) > 2:71 if parts[2] in ("tree", "blob"):72 if len(parts) < 4:73 raise InstallError("GitHub URL missing ref or path.")74 ref = parts[3]75 subpath = "/".join(parts[4:])76 else:77 subpath = "/".join(parts[2:])78 return owner, repo, ref, subpath or None79 80 81def _download_repo_zip(owner: str, repo: str, ref: str, dest_dir: str) -> str:82 zip_url = f"https://codeload.github.com/{owner}/{repo}/zip/{ref}"83 zip_path = os.path.join(dest_dir, "repo.zip")84 try:85 payload = _request(zip_url)86 except urllib.error.HTTPError as exc:87 raise InstallError(f"Download failed: HTTP {exc.code}") from exc88 with open(zip_path, "wb") as file_handle:89 file_handle.write(payload)90 with zipfile.ZipFile(zip_path, "r") as zip_file:91 _safe_extract_zip(zip_file, dest_dir)92 top_levels = {name.split("/")[0] for name in zip_file.namelist() if name}93 if not top_levels:94 raise InstallError("Downloaded archive was empty.")95 if len(top_levels) != 1:96 raise InstallError("Unexpected archive layout.")97 return os.path.join(dest_dir, next(iter(top_levels)))98 99 100def _run_git(args: list[str]) -> None:101 result = subprocess.run(102 args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True103 )104 if result.returncode != 0:105 raise InstallError(result.stderr.strip() or "Git command failed.")106 107 108def _safe_extract_zip(zip_file: zipfile.ZipFile, dest_dir: str) -> None:109 dest_root = os.path.realpath(dest_dir)110 for info in zip_file.infolist():111 extracted_path = os.path.realpath(os.path.join(dest_dir, info.filename))112 if extracted_path == dest_root or extracted_path.startswith(dest_root + os.sep):113 continue114 raise InstallError("Archive contains files outside the destination.")115 zip_file.extractall(dest_dir)116 117 118def _validate_relative_path(path: str) -> None:119 if os.path.isabs(path) or os.path.normpath(path).startswith(".."):120 raise InstallError("Skill path must be a relative path inside the repo.")121 122 123def _validate_skill_name(name: str) -> None:124 altsep = os.path.altsep125 if not name or os.path.sep in name or (altsep and altsep in name):126 raise InstallError("Skill name must be a single path segment.")127 if name in (".", ".."):128 raise InstallError("Invalid skill name.")129 130 131def _git_sparse_checkout(132 repo_url: str, ref: str, paths: list[str], dest_dir: str133) -> str:134 repo_dir = os.path.join(dest_dir, "repo")135 clone_cmd = [136 "git",137 "clone",138 "--filter=blob:none",139 "--depth",140 "1",141 "--sparse",142 "--single-branch",143 "--branch",144 ref,145 repo_url,146 repo_dir,147 ]148 try:149 _run_git(clone_cmd)150 except InstallError:151 _run_git(152 [153 "git",154 "clone",155 "--filter=blob:none",156 "--depth",157 "1",158 "--sparse",159 "--single-branch",160 repo_url,161 repo_dir,162 ]163 )164 _run_git(["git", "-C", repo_dir, "sparse-checkout", "set", *paths])165 _run_git(["git", "-C", repo_dir, "checkout", ref])166 return repo_dir167 168 169def _validate_skill(path: str, repo_root: str) -> None:170 resolved_repo_root = os.path.realpath(repo_root)171 resolved_path = os.path.realpath(path)172 try:173 inside_repo = (174 os.path.commonpath([resolved_repo_root, resolved_path])175 == resolved_repo_root176 )177 except ValueError:178 inside_repo = False179 if not inside_repo:180 raise InstallError("Skill path must be inside the repo.")181 182 relative_path = os.path.relpath(path, repo_root)183 current_path = repo_root184 for component in relative_path.split(os.path.sep):185 current_path = os.path.join(current_path, component)186 if os.path.islink(current_path):187 raise InstallError(188 f"Symbolic links are not allowed in skills: {os.path.relpath(current_path, repo_root)}"189 )190 191 if not os.path.isdir(path):192 raise InstallError(f"Skill path not found: {path}")193 skill_md = os.path.join(path, "SKILL.md")194 if not os.path.isfile(skill_md):195 raise InstallError("SKILL.md not found in selected skill directory.")196 197 for root, directories, files in os.walk(path):198 for name in directories + files:199 entry_path = os.path.join(root, name)200 relative_entry = os.path.relpath(entry_path, repo_root)201 if os.path.islink(entry_path):202 resolved_entry = os.path.realpath(entry_path)203 try:204 inside_skill = (205 os.path.commonpath([resolved_path, resolved_entry])206 == resolved_path207 )208 except ValueError:209 inside_skill = False210 if not inside_skill or not os.path.isfile(resolved_entry):211 raise InstallError(212 f"Unsupported symbolic link in skill: {relative_entry}"213 )214 continue215 if not os.path.isdir(entry_path) and not os.path.isfile(entry_path):216 raise InstallError(f"Unsupported file type in skill: {relative_entry}")217 218 219def _copy_skill(src: str, dest_dir: str) -> None:220 os.makedirs(os.path.dirname(dest_dir), exist_ok=True)221 if os.path.exists(dest_dir):222 raise InstallError(f"Destination already exists: {dest_dir}")223 shutil.copytree(src, dest_dir)224 225 226def _build_repo_url(owner: str, repo: str) -> str:227 return f"https://github.com/{owner}/{repo}.git"228 229 230def _build_repo_ssh(owner: str, repo: str) -> str:231 return f"git@github.com:{owner}/{repo}.git"232 233 234def _prepare_repo(source: Source, method: str, tmp_dir: str) -> str:235 if method in ("download", "auto"):236 try:237 return _download_repo_zip(source.owner, source.repo, source.ref, tmp_dir)238 except InstallError as exc:239 if method == "download":240 raise241 err_msg = str(exc)242 if "HTTP 401" in err_msg or "HTTP 403" in err_msg or "HTTP 404" in err_msg:243 pass244 else:245 raise246 if method in ("git", "auto"):247 repo_url = source.repo_url or _build_repo_url(source.owner, source.repo)248 try:249 return _git_sparse_checkout(repo_url, source.ref, source.paths, tmp_dir)250 except InstallError:251 repo_url = _build_repo_ssh(source.owner, source.repo)252 return _git_sparse_checkout(repo_url, source.ref, source.paths, tmp_dir)253 raise InstallError("Unsupported method.")254 255 256def _resolve_source(args: Args) -> Source:257 if args.url:258 owner, repo, ref, url_path = _parse_github_url(args.url, args.ref)259 if args.path is not None:260 paths = list(args.path)261 elif url_path:262 paths = [url_path]263 else:264 paths = []265 if not paths:266 raise InstallError("Missing --path for GitHub URL.")267 return Source(owner=owner, repo=repo, ref=ref, paths=paths)268 269 if not args.repo:270 raise InstallError("Provide --repo or --url.")271 if "://" in args.repo:272 return _resolve_source(273 Args(url=args.repo, repo=None, path=args.path, ref=args.ref)274 )275 276 repo_parts = [p for p in args.repo.split("/") if p]277 if len(repo_parts) != 2:278 raise InstallError("--repo must be in owner/repo format.")279 if not args.path:280 raise InstallError("Missing --path for --repo.")281 paths = list(args.path)282 return Source(283 owner=repo_parts[0],284 repo=repo_parts[1],285 ref=args.ref,286 paths=paths,287 )288 289 290def _default_dest() -> str:291 return os.path.join(_codex_home(), "skills")292 293 294def _parse_args(argv: list[str]) -> Args:295 parser = argparse.ArgumentParser(description="Install a skill from GitHub.")296 parser.add_argument("--repo", help="owner/repo")297 parser.add_argument("--url", help="https://github.com/owner/repo[/tree/ref/path]")298 parser.add_argument(299 "--path",300 nargs="+",301 help="Path(s) to skill(s) inside repo",302 )303 parser.add_argument("--ref", default=DEFAULT_REF)304 parser.add_argument("--dest", help="Destination skills directory")305 parser.add_argument(306 "--name", help="Destination skill name (defaults to basename of path)"307 )308 parser.add_argument(309 "--method",310 choices=["auto", "download", "git"],311 default="auto",312 )313 return parser.parse_args(argv, namespace=Args())314 315 316def main(argv: list[str]) -> int:317 args = _parse_args(argv)318 try:319 source = _resolve_source(args)320 source.ref = source.ref or args.ref321 if not source.paths:322 raise InstallError("No skill paths provided.")323 for path in source.paths:324 _validate_relative_path(path)325 dest_root = args.dest or _default_dest()326 tmp_dir = tempfile.mkdtemp(prefix="skill-install-", dir=_tmp_root())327 try:328 repo_root = _prepare_repo(source, args.method, tmp_dir)329 installed = []330 for path in source.paths:331 skill_name = args.name if len(source.paths) == 1 else None332 skill_name = skill_name or os.path.basename(path.rstrip("/"))333 _validate_skill_name(skill_name)334 if not skill_name:335 raise InstallError("Unable to derive skill name.")336 dest_dir = os.path.join(dest_root, skill_name)337 if os.path.exists(dest_dir):338 raise InstallError(f"Destination already exists: {dest_dir}")339 skill_src = os.path.join(repo_root, path)340 _validate_skill(skill_src, repo_root)341 _copy_skill(skill_src, dest_dir)342 installed.append((skill_name, dest_dir))343 finally:344 if os.path.isdir(tmp_dir):345 shutil.rmtree(tmp_dir, ignore_errors=True)346 for skill_name, dest_dir in installed:347 print(f"Installed {skill_name} to {dest_dir}")348 return 0349 except InstallError as exc:350 print(f"Error: {exc}", file=sys.stderr)351 return 1352 353 354if __name__ == "__main__":355 raise SystemExit(main(sys.argv[1:]))356 