Skip to content

Commit 01a4fc3

Browse files
Davide Gallitelliclaude
andcommitted
feat(skills): support GitHub /tree/ URLs for nested skills
Parse GitHub web URLs like /tree/<ref>/path to extract the clone URL, branch, and subdirectory path. This enables loading skills from subdirectories within mono-repos. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 07225e7 commit 01a4fc3

File tree

5 files changed

+193
-74
lines changed

5 files changed

+193
-74
lines changed

src/strands/vended_plugins/skills/_url_loader.py

Lines changed: 71 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
# Regex to strip .git suffix from URLs before ref parsing
2525
_GIT_SUFFIX = re.compile(r"\.git$")
2626

27+
# Matches GitHub /tree/<ref> or /tree/<ref>/<path> (also /blob/)
28+
# e.g. /owner/repo/tree/main/skills/my-skill -> groups: (/owner/repo, main, skills/my-skill)
29+
_GITHUB_TREE_PATTERN = re.compile(r"^(/[^/]+/[^/]+)/(?:tree|blob)/([^/]+)(?:/(.+?))?/?$")
30+
2731

2832
def is_url(source: str) -> bool:
2933
"""Check whether a skill source string looks like a remote URL.
@@ -37,31 +41,43 @@ def is_url(source: str) -> bool:
3741
return any(source.startswith(prefix) for prefix in _URL_PREFIXES)
3842

3943

40-
def parse_url_ref(url: str) -> tuple[str, str | None]:
41-
"""Parse a skill URL into a clone URL and an optional Git ref.
44+
def parse_url_ref(url: str) -> tuple[str, str | None, str | None]:
45+
"""Parse a skill URL into a clone URL, optional Git ref, and optional subpath.
4246
4347
Supports an ``@ref`` suffix for specifying a branch, tag, or commit::
4448
45-
https://github.com/org/skill-repo@v1.0.0 -> (https://github.com/org/skill-repo, v1.0.0)
46-
https://github.com/org/skill-repo -> (https://github.com/org/skill-repo, None)
47-
https://github.com/org/skill-repo.git@main -> (https://github.com/org/skill-repo.git, main)
48-
git@github.com:org/skill-repo.git@v2 -> (git@github.com:org/skill-repo.git, v2)
49+
https://github.com/org/skill-repo@v1.0.0 -> (https://github.com/org/skill-repo, v1.0.0, None)
50+
https://github.com/org/skill-repo -> (https://github.com/org/skill-repo, None, None)
51+
52+
Also supports GitHub web URLs with ``/tree/<ref>/path`` ::
53+
54+
https://github.com/org/repo/tree/main/skills/my-skill
55+
-> (https://github.com/org/repo, main, skills/my-skill)
4956
5057
Args:
51-
url: The skill URL, optionally with an ``@ref`` suffix.
58+
url: The skill URL, optionally with an ``@ref`` suffix or ``/tree/`` path.
5259
5360
Returns:
54-
Tuple of (clone_url, ref_or_none).
61+
Tuple of (clone_url, ref_or_none, subpath_or_none).
5562
"""
5663
if url.startswith(("https://", "http://", "ssh://")):
5764
# Find the path portion after the host
5865
scheme_end = url.index("//") + 2
5966
host_end = url.find("/", scheme_end)
6067
if host_end == -1:
61-
return url, None
68+
return url, None, None
6269

6370
path_part = url[host_end:]
6471

72+
# Handle GitHub /tree/<ref>/path and /blob/<ref>/path URLs
73+
tree_match = _GITHUB_TREE_PATTERN.match(path_part)
74+
if tree_match:
75+
owner_repo = tree_match.group(1)
76+
ref = tree_match.group(2)
77+
subpath = tree_match.group(3) or None
78+
clone_url = url[:host_end] + owner_repo
79+
return clone_url, ref, subpath
80+
6581
# Strip .git suffix before looking for @ref so that
6682
# "repo.git@v1" is handled correctly
6783
clean_path = _GIT_SUFFIX.sub("", path_part)
@@ -73,9 +89,9 @@ def parse_url_ref(url: str) -> tuple[str, str | None]:
7389
base_path = clean_path[:at_idx]
7490
if had_git_suffix:
7591
base_path += ".git"
76-
return url[:host_end] + base_path, ref
92+
return url[:host_end] + base_path, ref, None
7793

78-
return url, None
94+
return url, None, None
7995

8096
if url.startswith("git@"):
8197
# SSH format: git@host:owner/repo.git@ref
@@ -92,11 +108,11 @@ def parse_url_ref(url: str) -> tuple[str, str | None]:
92108
base_rest = clean_rest[:at_idx]
93109
if had_git_suffix:
94110
base_rest += ".git"
95-
return url[: first_at + 1] + base_rest, ref
111+
return url[: first_at + 1] + base_rest, ref, None
96112

97-
return url, None
113+
return url, None, None
98114

99-
return url, None
115+
return url, None, None
100116

101117

102118
def cache_key(url: str, ref: str | None) -> str:
@@ -117,6 +133,7 @@ def clone_skill_repo(
117133
url: str,
118134
*,
119135
ref: str | None = None,
136+
subpath: str | None = None,
120137
cache_dir: Path | None = None,
121138
) -> Path:
122139
"""Clone a skill repository to a local cache directory.
@@ -125,14 +142,19 @@ def clone_skill_repo(
125142
is passed as ``--branch`` (works for branches and tags). Repositories are
126143
cached by a hash of (url, ref) so repeated loads are instant.
127144
145+
If ``subpath`` is provided, the returned path points to that subdirectory
146+
within the cloned repository (useful for mono-repos containing skills in
147+
nested directories).
148+
128149
Args:
129150
url: The Git clone URL.
130151
ref: Optional branch or tag to check out.
152+
subpath: Optional path within the repo to return (e.g. ``skills/my-skill``).
131153
cache_dir: Override the default cache directory
132154
(``~/.cache/strands/skills/``).
133155
134156
Returns:
135-
Path to the cloned repository root.
157+
Path to the cloned repository root, or to ``subpath`` within it.
136158
137159
Raises:
138160
RuntimeError: If the clone fails or ``git`` is not installed.
@@ -143,32 +165,38 @@ def clone_skill_repo(
143165
key = cache_key(url, ref)
144166
target = cache_dir / key
145167

146-
if target.exists():
168+
if not target.exists():
169+
logger.info("url=<%s>, ref=<%s> | cloning skill repository", url, ref)
170+
171+
cmd: list[str] = ["git", "clone", "--depth", "1"]
172+
if ref:
173+
cmd.extend(["--branch", ref])
174+
cmd.extend([url, str(target)])
175+
176+
try:
177+
subprocess.run( # noqa: S603
178+
cmd,
179+
check=True,
180+
capture_output=True,
181+
text=True,
182+
timeout=120,
183+
)
184+
except subprocess.CalledProcessError as e:
185+
# Clean up any partial clone
186+
if target.exists():
187+
shutil.rmtree(target)
188+
raise RuntimeError(
189+
f"url=<{url}>, ref=<{ref}> | failed to clone skill repository: {e.stderr.strip()}"
190+
) from e
191+
except FileNotFoundError as e:
192+
raise RuntimeError("git is required to load skills from URLs but was not found on PATH") from e
193+
else:
147194
logger.debug("url=<%s>, ref=<%s> | using cached skill at %s", url, ref, target)
148-
return target
149-
150-
logger.info("url=<%s>, ref=<%s> | cloning skill repository", url, ref)
151-
152-
cmd: list[str] = ["git", "clone", "--depth", "1"]
153-
if ref:
154-
cmd.extend(["--branch", ref])
155-
cmd.extend([url, str(target)])
156-
157-
try:
158-
subprocess.run( # noqa: S603
159-
cmd,
160-
check=True,
161-
capture_output=True,
162-
text=True,
163-
timeout=120,
164-
)
165-
except subprocess.CalledProcessError as e:
166-
# Clean up any partial clone
167-
if target.exists():
168-
shutil.rmtree(target)
169-
raise RuntimeError(f"url=<{url}>, ref=<{ref}> | failed to clone skill repository: {e.stderr.strip()}") from e
170-
except FileNotFoundError as e:
171-
raise RuntimeError("git is required to load skills from URLs but was not found on PATH") from e
172-
173-
logger.debug("url=<%s>, ref=<%s> | cloned to %s", url, ref, target)
174-
return target
195+
196+
result = target / subpath if subpath else target
197+
198+
if subpath and not result.is_dir():
199+
raise RuntimeError(f"url=<{url}>, subpath=<{subpath}> | subdirectory does not exist in cloned repository")
200+
201+
logger.debug("url=<%s>, ref=<%s> | resolved to %s", url, ref, result)
202+
return result

src/strands/vended_plugins/skills/skill.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,13 @@ def from_url(
352352
353353
skills = Skill.from_url("https://github.com/org/my-skill@v1.0.0")
354354
355+
Also supports GitHub web URLs pointing to subdirectories::
356+
357+
skills = Skill.from_url("https://github.com/org/repo/tree/main/skills/my-skill")
358+
355359
Args:
356-
url: A Git-cloneable URL, optionally with an ``@ref`` suffix.
360+
url: A Git-cloneable URL, optionally with an ``@ref`` suffix or
361+
a GitHub ``/tree/<ref>/path`` URL.
357362
cache_dir: Override the default cache directory
358363
(``~/.cache/strands/skills/``).
359364
strict: If True, raise on any validation issue. If False (default),
@@ -371,8 +376,8 @@ def from_url(
371376
if not is_url(url):
372377
raise ValueError(f"url=<{url}> | not a valid remote URL")
373378

374-
clean_url, ref = parse_url_ref(url)
375-
repo_path = clone_skill_repo(clean_url, ref=ref, cache_dir=cache_dir)
379+
clean_url, ref, subpath = parse_url_ref(url)
380+
repo_path = clone_skill_repo(clean_url, ref=ref, subpath=subpath, cache_dir=cache_dir)
376381

377382
# If the repo root is itself a skill, load it directly
378383
has_skill_md = (repo_path / "SKILL.md").is_file() or (repo_path / "skill.md").is_file()

tests/strands/vended_plugins/skills/test_agent_skills.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,7 @@ def _mock_clone(self, tmp_path, skill_name="url-skill", description="A URL skill
670670
"""Create a mock clone function that creates a skill directory."""
671671
skill_dir = tmp_path / "cloned"
672672

673-
def fake_clone(url, *, ref=None, cache_dir=None):
673+
def fake_clone(url, *, ref=None, subpath=None, cache_dir=None):
674674
skill_dir.mkdir(parents=True, exist_ok=True)
675675
content = f"---\nname: {skill_name}\ndescription: {description}\n---\n# Instructions\n"
676676
(skill_dir / "SKILL.md").write_text(content)
@@ -688,7 +688,7 @@ def test_resolve_url_source(self, tmp_path):
688688
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=fake_clone),
689689
patch(
690690
f"{self._URL_LOADER}.parse_url_ref",
691-
return_value=("https://github.com/org/url-skill", None),
691+
return_value=("https://github.com/org/url-skill", None, None),
692692
),
693693
):
694694
plugin = AgentSkills(skills=["https://github.com/org/url-skill"])
@@ -707,7 +707,7 @@ def test_resolve_mixed_url_and_local(self, tmp_path):
707707
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=fake_clone),
708708
patch(
709709
f"{self._URL_LOADER}.parse_url_ref",
710-
return_value=("https://github.com/org/url-skill", None),
710+
return_value=("https://github.com/org/url-skill", None, None),
711711
),
712712
):
713713
plugin = AgentSkills(
@@ -729,7 +729,7 @@ def test_resolve_url_failure_skips_gracefully(self, caplog):
729729
with (
730730
patch(
731731
f"{self._URL_LOADER}.parse_url_ref",
732-
return_value=("https://github.com/org/broken", None),
732+
return_value=("https://github.com/org/broken", None, None),
733733
),
734734
patch(
735735
f"{self._URL_LOADER}.clone_skill_repo",
@@ -749,7 +749,7 @@ def test_cache_dir_forwarded(self, tmp_path):
749749
fake_clone = self._mock_clone(tmp_path)
750750
captured_cache = []
751751

752-
def tracking_clone(url, *, ref=None, cache_dir=None):
752+
def tracking_clone(url, *, ref=None, subpath=None, cache_dir=None):
753753
captured_cache.append(cache_dir)
754754
return fake_clone(url, ref=ref, cache_dir=cache_dir)
755755

@@ -759,7 +759,7 @@ def tracking_clone(url, *, ref=None, cache_dir=None):
759759
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=tracking_clone),
760760
patch(
761761
f"{self._URL_LOADER}.parse_url_ref",
762-
return_value=("https://github.com/org/url-skill", None),
762+
return_value=("https://github.com/org/url-skill", None, None),
763763
),
764764
):
765765
AgentSkills(skills=["https://github.com/org/url-skill"], cache_dir=custom_cache)

tests/strands/vended_plugins/skills/test_skill.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ def _mock_clone(self, tmp_path, skill_name="my-skill", description="A remote ski
560560
"""Create a mock clone function that creates a skill directory."""
561561
skill_dir = tmp_path / "cloned"
562562

563-
def fake_clone(url, *, ref=None, cache_dir=None):
563+
def fake_clone(url, *, ref=None, subpath=None, cache_dir=None):
564564
skill_dir.mkdir(parents=True, exist_ok=True)
565565
content = f"---\nname: {skill_name}\ndescription: {description}\n---\n{body}\n"
566566
(skill_dir / "SKILL.md").write_text(content)
@@ -572,7 +572,7 @@ def _mock_clone_multi(self, tmp_path):
572572
"""Create a mock clone function that creates a parent dir with multiple skills."""
573573
parent_dir = tmp_path / "cloned"
574574

575-
def fake_clone(url, *, ref=None, cache_dir=None):
575+
def fake_clone(url, *, ref=None, subpath=None, cache_dir=None):
576576
parent_dir.mkdir(parents=True, exist_ok=True)
577577
for name in ("skill-a", "skill-b"):
578578
child = parent_dir / name
@@ -590,7 +590,7 @@ def test_from_url_single_skill(self, tmp_path):
590590

591591
with (
592592
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=fake_clone),
593-
patch(f"{self._URL_LOADER}.parse_url_ref", return_value=("https://github.com/org/my-skill", None)),
593+
patch(f"{self._URL_LOADER}.parse_url_ref", return_value=("https://github.com/org/my-skill", None, None)),
594594
):
595595
skills = Skill.from_url("https://github.com/org/my-skill")
596596

@@ -609,7 +609,7 @@ def test_from_url_multiple_skills(self, tmp_path):
609609
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=fake_clone),
610610
patch(
611611
f"{self._URL_LOADER}.parse_url_ref",
612-
return_value=("https://github.com/org/skills-collection", None),
612+
return_value=("https://github.com/org/skills-collection", None, None),
613613
),
614614
):
615615
skills = Skill.from_url("https://github.com/org/skills-collection")
@@ -625,15 +625,15 @@ def test_from_url_with_ref(self, tmp_path):
625625
fake_clone = self._mock_clone(tmp_path)
626626
captured_ref = []
627627

628-
def tracking_clone(url, *, ref=None, cache_dir=None):
628+
def tracking_clone(url, *, ref=None, subpath=None, cache_dir=None):
629629
captured_ref.append(ref)
630630
return fake_clone(url, ref=ref, cache_dir=cache_dir)
631631

632632
with (
633633
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=tracking_clone),
634634
patch(
635635
f"{self._URL_LOADER}.parse_url_ref",
636-
return_value=("https://github.com/org/my-skill", "v1.0.0"),
636+
return_value=("https://github.com/org/my-skill", "v1.0.0", None),
637637
),
638638
):
639639
Skill.from_url("https://github.com/org/my-skill@v1.0.0")
@@ -652,7 +652,7 @@ def test_from_url_clone_failure_raises(self):
652652
with (
653653
patch(
654654
f"{self._URL_LOADER}.parse_url_ref",
655-
return_value=("https://github.com/org/broken", None),
655+
return_value=("https://github.com/org/broken", None, None),
656656
),
657657
patch(
658658
f"{self._URL_LOADER}.clone_skill_repo",
@@ -669,7 +669,7 @@ def test_from_url_passes_cache_dir(self, tmp_path):
669669
fake_clone = self._mock_clone(tmp_path)
670670
captured_cache = []
671671

672-
def tracking_clone(url, *, ref=None, cache_dir=None):
672+
def tracking_clone(url, *, ref=None, subpath=None, cache_dir=None):
673673
captured_cache.append(cache_dir)
674674
return fake_clone(url, ref=ref, cache_dir=cache_dir)
675675

@@ -679,7 +679,7 @@ def tracking_clone(url, *, ref=None, cache_dir=None):
679679
patch(f"{self._URL_LOADER}.clone_skill_repo", side_effect=tracking_clone),
680680
patch(
681681
f"{self._URL_LOADER}.parse_url_ref",
682-
return_value=("https://github.com/org/my-skill", None),
682+
return_value=("https://github.com/org/my-skill", None, None),
683683
),
684684
):
685685
Skill.from_url("https://github.com/org/my-skill", cache_dir=custom_cache)

0 commit comments

Comments
 (0)