Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions agr/handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
59 changes: 59 additions & 0 deletions tests/test_handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"