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
2832def 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
102118def 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
0 commit comments