-
Notifications
You must be signed in to change notification settings - Fork 816
feat(skills): support loading skills from URLs #2091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mkmeral
merged 9 commits into
strands-agents:main
from
dgallitelli:feat/skills-github-url-loading
Apr 15, 2026
+242
−7
Merged
Changes from 2 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
a7bddae
feat(skills): support loading skills from GitHub/Git URLs
83966f6
feat(skills): support GitHub /tree/ URLs for nested skills
7f89fd8
fix(skills): address review feedback on URL loading
393dcd7
refactor(skills): replace git clone with HTTPS-only fetch
d049e09
fix(skills): address v2 review feedback
bfa2951
simplify(skills): remove GitHub URL resolution per maintainer feedback
b11b4e3
simplify(skills): inline URL fetch into Skill, remove _url_loader.py
8db72a7
fix(skills): fix stale docstring, add invalid content test
759d822
fix(skills): update set_available_skills docstring, add duplicate URL…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| """Utilities for loading skills from remote Git repository URLs. | ||
|
|
||
| This module provides functions to detect URL-type skill sources, parse | ||
| optional version references, clone repositories with shallow depth, and | ||
| manage a local cache of cloned skill repositories. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import logging | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _DEFAULT_CACHE_DIR = Path.home() / ".cache" / "strands" / "skills" | ||
|
|
||
| # Patterns that indicate a string is a URL rather than a local path | ||
| _URL_PREFIXES = ("https://", "http://", "git@", "ssh://") | ||
|
mkmeral marked this conversation as resolved.
Outdated
|
||
|
|
||
| # Regex to strip .git suffix from URLs before ref parsing | ||
| _GIT_SUFFIX = re.compile(r"\.git$") | ||
|
|
||
| # Matches GitHub /tree/<ref> or /tree/<ref>/<path> (also /blob/) | ||
| # e.g. /owner/repo/tree/main/skills/my-skill -> groups: (/owner/repo, main, skills/my-skill) | ||
| _GITHUB_TREE_PATTERN = re.compile(r"^(/[^/]+/[^/]+)/(?:tree|blob)/([^/]+)(?:/(.+?))?/?$") | ||
|
|
||
|
|
||
| def is_url(source: str) -> bool: | ||
| """Check whether a skill source string looks like a remote URL. | ||
|
|
||
| Args: | ||
| source: The skill source string to check. | ||
|
|
||
| Returns: | ||
| True if the source appears to be a URL. | ||
| """ | ||
| return any(source.startswith(prefix) for prefix in _URL_PREFIXES) | ||
|
|
||
|
|
||
| def parse_url_ref(url: str) -> tuple[str, str | None, str | None]: | ||
| """Parse a skill URL into a clone URL, optional Git ref, and optional subpath. | ||
|
|
||
| Supports an ``@ref`` suffix for specifying a branch, tag, or commit:: | ||
|
|
||
| https://github.com/org/skill-repo@v1.0.0 -> (https://github.com/org/skill-repo, v1.0.0, None) | ||
| https://github.com/org/skill-repo -> (https://github.com/org/skill-repo, None, None) | ||
|
|
||
| Also supports GitHub web URLs with ``/tree/<ref>/path`` :: | ||
|
|
||
| https://github.com/org/repo/tree/main/skills/my-skill | ||
| -> (https://github.com/org/repo, main, skills/my-skill) | ||
|
|
||
| Args: | ||
| url: The skill URL, optionally with an ``@ref`` suffix or ``/tree/`` path. | ||
|
|
||
| Returns: | ||
| Tuple of (clone_url, ref_or_none, subpath_or_none). | ||
| """ | ||
| if url.startswith(("https://", "http://", "ssh://")): | ||
| # Find the path portion after the host | ||
| scheme_end = url.index("//") + 2 | ||
| host_end = url.find("/", scheme_end) | ||
| if host_end == -1: | ||
| return url, None, None | ||
|
|
||
| path_part = url[host_end:] | ||
|
|
||
| # Handle GitHub /tree/<ref>/path and /blob/<ref>/path URLs | ||
| tree_match = _GITHUB_TREE_PATTERN.match(path_part) | ||
| if tree_match: | ||
| owner_repo = tree_match.group(1) | ||
| ref = tree_match.group(2) | ||
| subpath = tree_match.group(3) or None | ||
| clone_url = url[:host_end] + owner_repo | ||
| return clone_url, ref, subpath | ||
|
|
||
| # Strip .git suffix before looking for @ref so that | ||
| # "repo.git@v1" is handled correctly | ||
| clean_path = _GIT_SUFFIX.sub("", path_part) | ||
| had_git_suffix = clean_path != path_part | ||
|
|
||
| if "@" in clean_path: | ||
| at_idx = clean_path.rfind("@") | ||
| ref = clean_path[at_idx + 1 :] | ||
| base_path = clean_path[:at_idx] | ||
| if had_git_suffix: | ||
| base_path += ".git" | ||
| return url[:host_end] + base_path, ref, None | ||
|
|
||
| return url, None, None | ||
|
|
||
| if url.startswith("git@"): | ||
| # SSH format: git@host:owner/repo.git@ref | ||
| # The first @ is part of the SSH URL format. | ||
| first_at = url.index("@") | ||
| rest = url[first_at + 1 :] | ||
|
|
||
| clean_rest = _GIT_SUFFIX.sub("", rest) | ||
| had_git_suffix = clean_rest != rest | ||
|
|
||
| if "@" in clean_rest: | ||
| at_idx = clean_rest.rfind("@") | ||
| ref = clean_rest[at_idx + 1 :] | ||
| base_rest = clean_rest[:at_idx] | ||
| if had_git_suffix: | ||
| base_rest += ".git" | ||
| return url[: first_at + 1] + base_rest, ref, None | ||
|
|
||
| return url, None, None | ||
|
|
||
| return url, None, None | ||
|
|
||
|
|
||
| def cache_key(url: str, ref: str | None) -> str: | ||
| """Generate a deterministic cache directory name from a URL and ref. | ||
|
|
||
| Args: | ||
| url: The clone URL. | ||
| ref: The optional Git ref. | ||
|
|
||
| Returns: | ||
| A short hex digest suitable for use as a directory name. | ||
| """ | ||
| key_input = f"{url}@{ref}" if ref else url | ||
| return hashlib.sha256(key_input.encode()).hexdigest()[:16] | ||
|
|
||
|
|
||
| def clone_skill_repo( | ||
|
mkmeral marked this conversation as resolved.
Outdated
|
||
| url: str, | ||
| *, | ||
| ref: str | None = None, | ||
| subpath: str | None = None, | ||
| cache_dir: Path | None = None, | ||
| ) -> Path: | ||
| """Clone a skill repository to a local cache directory. | ||
|
|
||
| Uses ``git clone --depth 1`` for efficiency. If a ``ref`` is provided it | ||
| is passed as ``--branch`` (works for branches and tags). Repositories are | ||
| cached by a hash of (url, ref) so repeated loads are instant. | ||
|
|
||
| If ``subpath`` is provided, the returned path points to that subdirectory | ||
| within the cloned repository (useful for mono-repos containing skills in | ||
| nested directories). | ||
|
|
||
| Args: | ||
| url: The Git clone URL. | ||
| ref: Optional branch or tag to check out. | ||
| subpath: Optional path within the repo to return (e.g. ``skills/my-skill``). | ||
| cache_dir: Override the default cache directory | ||
| (``~/.cache/strands/skills/``). | ||
|
|
||
| Returns: | ||
| Path to the cloned repository root, or to ``subpath`` within it. | ||
|
|
||
| Raises: | ||
| RuntimeError: If the clone fails or ``git`` is not installed. | ||
| """ | ||
| cache_dir = cache_dir or _DEFAULT_CACHE_DIR | ||
| cache_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| key = cache_key(url, ref) | ||
| target = cache_dir / key | ||
|
|
||
| if not target.exists(): | ||
|
mkmeral marked this conversation as resolved.
Outdated
|
||
| logger.info("url=<%s>, ref=<%s> | cloning skill repository", url, ref) | ||
|
|
||
| cmd: list[str] = ["git", "clone", "--depth", "1"] | ||
| if ref: | ||
| cmd.extend(["--branch", ref]) | ||
| cmd.extend([url, str(target)]) | ||
|
|
||
| try: | ||
| subprocess.run( # noqa: S603 | ||
| cmd, | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=120, | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| # Clean up any partial clone | ||
| if target.exists(): | ||
| shutil.rmtree(target) | ||
| raise RuntimeError( | ||
| f"url=<{url}>, ref=<{ref}> | failed to clone skill repository: {e.stderr.strip()}" | ||
| ) from e | ||
| except FileNotFoundError as e: | ||
| raise RuntimeError("git is required to load skills from URLs but was not found on PATH") from e | ||
| else: | ||
| logger.debug("url=<%s>, ref=<%s> | using cached skill at %s", url, ref, target) | ||
|
|
||
| result = target / subpath if subpath else target | ||
|
|
||
| if subpath and not result.is_dir(): | ||
| raise RuntimeError(f"url=<{url}>, subpath=<{subpath}> | subdirectory does not exist in cloned repository") | ||
|
|
||
| logger.debug("url=<%s>, ref=<%s> | resolved to %s", url, ref, result) | ||
| return result | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.