diff --git a/agr/handle.py b/agr/handle.py index 8a1e581..753e32b 100644 --- a/agr/handle.py +++ b/agr/handle.py @@ -2,6 +2,7 @@ Handle formats: - Remote: "username/skill" or "username/repo/skill" +- Remote URL: "https://github.com/user/repo/tree/branch/path/to/skill" - Local: "./path/to/skill" or "path/to/skill" Installed naming (Windows-compatible using -- separator) used on collisions: @@ -16,6 +17,7 @@ import warnings from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlparse from typing import TYPE_CHECKING from agr.exceptions import InvalidHandleError @@ -206,6 +208,11 @@ def parse_handle(ref: str, *, prefer_local: bool = True) -> ParsedHandle: ref = ref.strip() + # Try to parse as a GitHub URL first + normalized = _try_parse_github_url(ref) + if normalized is not None: + ref = normalized + if prefer_local: path = Path(ref) # Local path detection: starts with ./ ../ / or exists on disk @@ -254,6 +261,60 @@ def parse_handle(ref: str, *, prefer_local: bool = True) -> ParsedHandle: ) +def _try_parse_github_url(ref: str) -> str | None: + """Try to parse a GitHub URL into a handle string. + + Converts URLs like: + https://github.com/user/repo/tree/branch/path/to/skill + Into handle strings like: + user/repo/skill + + Args: + ref: The input string that might be a GitHub URL. + + Returns: + Normalized handle string if the input is a GitHub URL, None otherwise. + + Raises: + InvalidHandleError: If it looks like a GitHub URL but can't be parsed. + """ + # Fast path: skip urlparse for non-URL inputs + if "://" not in ref: + return None + + parsed = urlparse(ref) + if parsed.hostname != "github.com": + return None + + # Strip leading/trailing slashes and split + path_parts = [p for p in parsed.path.split("/") if p] + + if len(path_parts) < 2: + raise InvalidHandleError( + f"Invalid GitHub URL '{ref}': expected at least user/repo in the path" + ) + + username = path_parts[0] + repo = path_parts[1] + + # Bare repo URL: https://github.com/user/repo + if len(path_parts) == 2: + return f"{username}/{repo}" + + # URL with /tree/branch/... or /blob/branch/... + if len(path_parts) >= 4 and path_parts[2] in ("tree", "blob"): + # path after branch — e.g. ["skills", "sample"] from /tree/main/skills/sample + skill_path = path_parts[4:] + if skill_path: + return f"{username}/{repo}/{skill_path[-1]}" + # Just https://github.com/user/repo/tree/branch (no subpath) + return f"{username}/{repo}" + + raise InvalidHandleError( + f"Invalid GitHub URL '{ref}': could not extract skill path. " + f"Expected format: https://github.com/user/repo/tree/branch/path/to/skill" + ) + def _validate_no_separator(ref: str, label: str, value: str) -> None: """Validate that a handle component doesn't contain the reserved separator. diff --git a/tests/test_handle.py b/tests/test_handle.py index fcf1fee..7de2c89 100644 --- a/tests/test_handle.py +++ b/tests/test_handle.py @@ -193,3 +193,62 @@ def test_default_candidates(self): def test_explicit_repo(self): """Explicit repo does not include legacy fallback.""" assert iter_repo_candidates("custom") == [("custom", False)] + + +class TestParseGitHubUrl: + """Tests for GitHub URL handling in parse_handle.""" + + def test_full_tree_url(self): + """Full GitHub tree URL extracts user/repo/skill.""" + h = parse_handle("https://github.com/user/repo/tree/main/skills/sample") + assert h.username == "user" + assert h.repo == "repo" + assert h.name == "sample" + assert h.is_remote + + def test_bare_repo_url(self): + """Bare GitHub repo URL extracts user/repo.""" + h = parse_handle("https://github.com/user/commit") + assert h.username == "user" + assert h.name == "commit" + assert h.repo is None + assert h.is_remote + + def test_url_with_trailing_slash(self): + """Trailing slash is handled correctly.""" + h = parse_handle("https://github.com/user/repo/tree/main/skills/sample/") + assert h.username == "user" + assert h.repo == "repo" + assert h.name == "sample" + + def test_blob_url(self): + """GitHub blob URL also works.""" + h = parse_handle("https://github.com/user/repo/blob/main/skills/my-skill") + assert h.username == "user" + assert h.repo == "repo" + assert h.name == "my-skill" + + def test_url_with_branch_only(self): + """URL with just /tree/branch returns user/repo.""" + h = parse_handle("https://github.com/user/repo/tree/main") + assert h.username == "user" + assert h.name == "repo" + assert h.repo is None + + def test_invalid_github_url_too_short(self): + """GitHub URL with only username raises error.""" + with pytest.raises(InvalidHandleError, match="expected at least user/repo"): + parse_handle("https://github.com/user") + + def test_non_github_url_not_matched(self): + """Non-GitHub URLs fall through to normal parsing.""" + with pytest.raises(InvalidHandleError): + parse_handle("https://gitlab.com/user/repo/tree/main/skill") + + def test_http_url(self): + """HTTP (non-HTTPS) GitHub URLs also work.""" + h = parse_handle("http://github.com/user/repo/tree/main/skills/sample") + assert h.username == "user" + assert h.repo == "repo" + assert h.name == "sample" +