Skip to content

Commit 09902bd

Browse files
dgallitelliDavide Gallitelliclaude
authored
feat(skills): support loading skills from URLs (#2091)
Co-authored-by: Davide Gallitelli <davidegallitelli@gmail.con> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b81401 commit 09902bd

4 files changed

Lines changed: 242 additions & 7 deletions

File tree

src/strands/vended_plugins/skills/agent_skills.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def __init__(
8686
- A ``str`` or ``Path`` to a skill directory (containing SKILL.md)
8787
- A ``str`` or ``Path`` to a parent directory (containing skill subdirectories)
8888
- A ``Skill`` dataclass instance
89+
- An ``https://`` URL pointing directly to raw SKILL.md content
8990
state_key: Key used to store plugin state in ``agent.state``.
9091
max_resource_files: Maximum number of resource files to list in skill responses.
9192
strict: If True, raise on skill validation issues. If False (default), warn and load anyway.
@@ -176,8 +177,9 @@ def set_available_skills(self, skills: SkillSources) -> None:
176177
"""Set the available skills, replacing any existing ones.
177178
178179
Each element can be a ``Skill`` instance, a ``str`` or ``Path`` to a
179-
skill directory (containing SKILL.md), or a ``str`` or ``Path`` to a
180-
parent directory containing skill subdirectories.
180+
skill directory (containing SKILL.md), a ``str`` or ``Path`` to a
181+
parent directory containing skill subdirectories, or an ``https://``
182+
URL pointing directly to raw SKILL.md content.
181183
182184
Note: this does not persist state or deactivate skills on any agent.
183185
Active skill state is managed per-agent and will be reconciled on the
@@ -284,7 +286,8 @@ def _resolve_skills(self, sources: list[SkillSource]) -> dict[str, Skill]:
284286
"""Resolve a list of skill sources into Skill instances.
285287
286288
Each source can be a Skill instance, a path to a skill directory,
287-
or a path to a parent directory containing multiple skills.
289+
a path to a parent directory containing multiple skills, or an
290+
HTTPS URL pointing to a SKILL.md file.
288291
289292
Args:
290293
sources: List of skill sources to resolve.
@@ -299,6 +302,14 @@ def _resolve_skills(self, sources: list[SkillSource]) -> dict[str, Skill]:
299302
if source.name in resolved:
300303
logger.warning("name=<%s> | duplicate skill name, overwriting previous skill", source.name)
301304
resolved[source.name] = source
305+
elif isinstance(source, str) and source.startswith("https://"):
306+
try:
307+
skill = Skill.from_url(source, strict=self._strict)
308+
if skill.name in resolved:
309+
logger.warning("name=<%s> | duplicate skill name, overwriting previous skill", skill.name)
310+
resolved[skill.name] = skill
311+
except (RuntimeError, ValueError) as e:
312+
logger.warning("url=<%s> | failed to load skill from URL: %s", source, e)
302313
else:
303314
path = Path(source).resolve()
304315
if not path.exists():

src/strands/vended_plugins/skills/skill.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"""Skill data model and loading utilities for AgentSkills.io skills.
22
33
This module defines the Skill dataclass and provides classmethods for
4-
discovering, parsing, and loading skills from the filesystem or raw content.
5-
Skills are directories containing a SKILL.md file with YAML frontmatter
6-
metadata and markdown instructions.
4+
discovering, parsing, and loading skills from the filesystem, raw content,
5+
or HTTPS URLs. Skills are directories containing a SKILL.md file with YAML
6+
frontmatter metadata and markdown instructions.
77
"""
88

99
from __future__ import annotations
1010

1111
import logging
1212
import re
13+
import urllib.error
14+
import urllib.request
1315
from dataclasses import dataclass, field
1416
from pathlib import Path
1517
from typing import Any
@@ -222,6 +224,9 @@ class Skill:
222224
# Load all skills from a parent directory
223225
skills = Skill.from_directory("./skills/")
224226
227+
# From an HTTPS URL
228+
skill = Skill.from_url("https://example.com/SKILL.md")
229+
225230
Attributes:
226231
name: Unique identifier for the skill (1-64 chars, lowercase alphanumeric + hyphens).
227232
description: Human-readable description of what the skill does.
@@ -333,6 +338,48 @@ def from_content(cls, content: str, *, strict: bool = False) -> Skill:
333338

334339
return _build_skill_from_frontmatter(frontmatter, body)
335340

341+
@classmethod
342+
def from_url(cls, url: str, *, strict: bool = False) -> Skill:
343+
"""Load a skill by fetching its SKILL.md content from an HTTPS URL.
344+
345+
Fetches the raw SKILL.md content over HTTPS and parses it using
346+
:meth:`from_content`. The URL must point directly to the raw
347+
file content (not an HTML page).
348+
349+
Example::
350+
351+
skill = Skill.from_url(
352+
"https://raw.githubusercontent.com/org/repo/main/SKILL.md"
353+
)
354+
355+
Args:
356+
url: An ``https://`` URL pointing directly to raw SKILL.md content.
357+
strict: If True, raise on any validation issue. If False (default),
358+
warn and load anyway.
359+
360+
Returns:
361+
A Skill instance populated from the fetched SKILL.md content.
362+
363+
Raises:
364+
ValueError: If ``url`` is not an ``https://`` URL.
365+
RuntimeError: If the SKILL.md content cannot be fetched.
366+
"""
367+
if not url.startswith("https://"):
368+
raise ValueError(f"url=<{url}> | not a valid HTTPS URL")
369+
370+
logger.info("url=<%s> | fetching skill content", url)
371+
372+
try:
373+
req = urllib.request.Request(url, headers={"User-Agent": "strands-agents-sdk"}) # noqa: S310
374+
with urllib.request.urlopen(req, timeout=30) as response: # noqa: S310
375+
content: str = response.read().decode("utf-8")
376+
except urllib.error.HTTPError as e:
377+
raise RuntimeError(f"url=<{url}> | HTTP {e.code}: {e.reason}") from e
378+
except urllib.error.URLError as e:
379+
raise RuntimeError(f"url=<{url}> | failed to fetch skill: {e.reason}") from e
380+
381+
return cls.from_content(content, strict=strict)
382+
336383
@classmethod
337384
def from_directory(cls, skills_dir: str | Path, *, strict: bool = False) -> list[Skill]:
338385
"""Load all skills from a parent directory containing skill subdirectories.

tests/strands/vended_plugins/skills/test_agent_skills.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,95 @@ def test_resolve_nonexistent_path(self, tmp_path):
661661
assert len(plugin._skills) == 0
662662

663663

664+
class TestResolveUrlSkills:
665+
"""Tests for _resolve_skills with URL sources."""
666+
667+
_SKILL_MODULE = "strands.vended_plugins.skills.skill"
668+
_SAMPLE_CONTENT = "---\nname: url-skill\ndescription: A URL skill\n---\n# Instructions\n"
669+
670+
def _mock_urlopen(self, content):
671+
"""Create a mock urlopen context manager returning the given content."""
672+
mock_response = MagicMock()
673+
mock_response.read.return_value = content.encode("utf-8")
674+
mock_response.__enter__ = MagicMock(return_value=mock_response)
675+
mock_response.__exit__ = MagicMock(return_value=False)
676+
return mock_response
677+
678+
def test_resolve_url_source(self):
679+
"""Test resolving a URL string as a skill source."""
680+
from unittest.mock import patch
681+
682+
with patch(
683+
f"{self._SKILL_MODULE}.urllib.request.urlopen", return_value=self._mock_urlopen(self._SAMPLE_CONTENT)
684+
):
685+
plugin = AgentSkills(skills=["https://example.com/SKILL.md"])
686+
687+
assert len(plugin.get_available_skills()) == 1
688+
assert plugin.get_available_skills()[0].name == "url-skill"
689+
690+
def test_resolve_mixed_url_and_local(self, tmp_path):
691+
"""Test resolving a mix of URL and local filesystem sources."""
692+
from unittest.mock import patch
693+
694+
_make_skill_dir(tmp_path, "local-skill")
695+
696+
with patch(
697+
f"{self._SKILL_MODULE}.urllib.request.urlopen", return_value=self._mock_urlopen(self._SAMPLE_CONTENT)
698+
):
699+
plugin = AgentSkills(
700+
skills=[
701+
"https://example.com/SKILL.md",
702+
str(tmp_path / "local-skill"),
703+
]
704+
)
705+
706+
assert len(plugin.get_available_skills()) == 2
707+
names = {s.name for s in plugin.get_available_skills()}
708+
assert names == {"url-skill", "local-skill"}
709+
710+
def test_resolve_url_failure_skips_gracefully(self, caplog):
711+
"""Test that a failed URL fetch is skipped with a warning."""
712+
import logging
713+
import urllib.error
714+
from unittest.mock import patch
715+
716+
with (
717+
patch(
718+
f"{self._SKILL_MODULE}.urllib.request.urlopen",
719+
side_effect=urllib.error.HTTPError(
720+
url="https://example.com", code=404, msg="Not Found", hdrs=None, fp=None
721+
),
722+
),
723+
caplog.at_level(logging.WARNING),
724+
):
725+
plugin = AgentSkills(skills=["https://example.com/broken/SKILL.md"])
726+
727+
assert len(plugin.get_available_skills()) == 0
728+
assert "failed to load skill from URL" in caplog.text
729+
730+
def test_resolve_duplicate_url_skills_warns(self, caplog):
731+
"""Test that duplicate skill names from URLs log a warning."""
732+
import logging
733+
from unittest.mock import patch
734+
735+
with (
736+
patch(
737+
f"{self._SKILL_MODULE}.urllib.request.urlopen",
738+
return_value=self._mock_urlopen(self._SAMPLE_CONTENT),
739+
),
740+
caplog.at_level(logging.WARNING),
741+
):
742+
plugin = AgentSkills(
743+
skills=[
744+
"https://example.com/a/SKILL.md",
745+
"https://example.com/b/SKILL.md",
746+
]
747+
)
748+
749+
assert len(plugin.get_available_skills()) == 1
750+
assert "duplicate skill name" in caplog.text
751+
752+
664753
class TestImports:
665754
"""Tests for module imports."""
666755

tests/strands/vended_plugins/skills/test_skill.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,11 +551,99 @@ def test_strict_mode(self):
551551
Skill.from_content(content, strict=True)
552552

553553

554+
class TestSkillFromUrl:
555+
"""Tests for Skill.from_url."""
556+
557+
_SKILL_MODULE = "strands.vended_plugins.skills.skill"
558+
_SAMPLE_CONTENT = "---\nname: my-skill\ndescription: A remote skill\n---\nRemote instructions.\n"
559+
560+
def _mock_urlopen(self, content):
561+
"""Create a mock urlopen context manager returning the given content."""
562+
from unittest.mock import MagicMock
563+
564+
mock_response = MagicMock()
565+
mock_response.read.return_value = content.encode("utf-8")
566+
mock_response.__enter__ = MagicMock(return_value=mock_response)
567+
mock_response.__exit__ = MagicMock(return_value=False)
568+
return mock_response
569+
570+
def test_from_url_returns_skill(self):
571+
"""Test loading a skill from a URL returns a single Skill."""
572+
from unittest.mock import patch
573+
574+
mock_response = self._mock_urlopen(self._SAMPLE_CONTENT)
575+
with patch(f"{self._SKILL_MODULE}.urllib.request.urlopen", return_value=mock_response):
576+
skill = Skill.from_url("https://raw.githubusercontent.com/org/repo/main/SKILL.md")
577+
578+
assert isinstance(skill, Skill)
579+
assert skill.name == "my-skill"
580+
assert skill.description == "A remote skill"
581+
assert "Remote instructions." in skill.instructions
582+
assert skill.path is None
583+
584+
def test_from_url_invalid_url_raises(self):
585+
"""Test that a non-HTTPS URL raises ValueError."""
586+
with pytest.raises(ValueError, match="not a valid HTTPS URL"):
587+
Skill.from_url("./local-path")
588+
589+
def test_from_url_http_rejected(self):
590+
"""Test that http:// URLs are rejected."""
591+
with pytest.raises(ValueError, match="not a valid HTTPS URL"):
592+
Skill.from_url("http://example.com/SKILL.md")
593+
594+
def test_from_url_http_error_raises(self):
595+
"""Test that HTTP errors propagate as RuntimeError."""
596+
import urllib.error
597+
from unittest.mock import patch
598+
599+
with patch(
600+
f"{self._SKILL_MODULE}.urllib.request.urlopen",
601+
side_effect=urllib.error.HTTPError(
602+
url="https://example.com", code=404, msg="Not Found", hdrs=None, fp=None
603+
),
604+
):
605+
with pytest.raises(RuntimeError, match="HTTP 404"):
606+
Skill.from_url("https://example.com/SKILL.md")
607+
608+
def test_from_url_network_error_raises(self):
609+
"""Test that network errors propagate as RuntimeError."""
610+
import urllib.error
611+
from unittest.mock import patch
612+
613+
with patch(
614+
f"{self._SKILL_MODULE}.urllib.request.urlopen",
615+
side_effect=urllib.error.URLError("Connection refused"),
616+
):
617+
with pytest.raises(RuntimeError, match="failed to fetch"):
618+
Skill.from_url("https://example.com/SKILL.md")
619+
620+
def test_from_url_strict_mode(self):
621+
"""Test that strict mode is forwarded to from_content."""
622+
from unittest.mock import patch
623+
624+
bad_content = "---\nname: BAD_NAME\ndescription: Bad\n---\nBody."
625+
626+
with patch(f"{self._SKILL_MODULE}.urllib.request.urlopen", return_value=self._mock_urlopen(bad_content)):
627+
with pytest.raises(ValueError):
628+
Skill.from_url("https://example.com/SKILL.md", strict=True)
629+
630+
def test_from_url_invalid_content_raises(self):
631+
"""Test that non-SKILL.md content (e.g. HTML page) raises ValueError."""
632+
from unittest.mock import patch
633+
634+
html_content = "<html><body>Not a SKILL.md</body></html>"
635+
636+
with patch(f"{self._SKILL_MODULE}.urllib.request.urlopen", return_value=self._mock_urlopen(html_content)):
637+
with pytest.raises(ValueError, match="frontmatter"):
638+
Skill.from_url("https://example.com/SKILL.md")
639+
640+
554641
class TestSkillClassmethods:
555642
"""Tests for Skill classmethod existence."""
556643

557644
def test_skill_classmethods_exist(self):
558-
"""Test that Skill has from_file, from_content, and from_directory classmethods."""
645+
"""Test that Skill has from_file, from_content, from_directory, and from_url classmethods."""
559646
assert callable(getattr(Skill, "from_file", None))
560647
assert callable(getattr(Skill, "from_content", None))
561648
assert callable(getattr(Skill, "from_directory", None))
649+
assert callable(getattr(Skill, "from_url", None))

0 commit comments

Comments
 (0)