Skip to content
Merged
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
10 changes: 7 additions & 3 deletions workspace/adapter_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,19 @@ async def _common_setup(self, config: AdapterConfig) -> SetupResult:
if plugins.plugin_names:
logger.info(f"Plugins: {', '.join(plugins.plugin_names)}")

# Load skills (workspace + plugin skills, deduped)
loaded_skills = load_skills(config.config_path, config.tools)
# Load skills (workspace + plugin skills, deduped). Pass the runtime
# name so SKILL.md frontmatter `runtime: [...]` can opt skills out
# of incompatible adapters (hermes won't load claude-code-only
# skills, etc.).
runtime_name = type(self).name()
loaded_skills = load_skills(config.config_path, config.tools, current_runtime=runtime_name)
seen_skill_ids = {s.metadata.id for s in loaded_skills}
for plugin_skills_dir in plugins.skill_dirs:
plugin_skill_names = [
d for d in os.listdir(plugin_skills_dir)
if os.path.isdir(os.path.join(plugin_skills_dir, d))
]
for skill in load_skills(plugin_skills_dir, plugin_skill_names):
for skill in load_skills(plugin_skills_dir, plugin_skill_names, current_runtime=runtime_name):
if skill.metadata.id not in seen_skill_ids:
loaded_skills.append(skill)
seen_skill_ids.add(skill.metadata.id)
Expand Down
1 change: 1 addition & 0 deletions workspace/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ def _on_skill_reload(updated_skill):
config_path=config_path,
skill_names=config.skills,
on_reload=_on_skill_reload,
current_runtime=runtime,
)
asyncio.create_task(skills_watcher.start())
print(f"Skills hot-reload enabled for: {config.skills}")
Expand Down
50 changes: 48 additions & 2 deletions workspace/skill_loader/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class SkillMetadata:
description: str
tags: list[str] = field(default_factory=list)
examples: list[str] = field(default_factory=list)
# Runtime compatibility — list of adapter `name()` values this skill
# supports, or ["*"] for universal. Borrowed from hermes' declarative
# skill-compat pattern: a skill that depends on claude-code-only tools
# should declare `runtime: [claude-code]` so hermes (or any other
# adapter) skips it at load time instead of failing at first invocation.
runtime: list[str] = field(default_factory=lambda: ["*"])


@dataclass
Expand Down Expand Up @@ -133,8 +139,39 @@ def load_skill_tools(scripts_dir: Path) -> list[Any]:
return tools


def load_skills(config_path: str, skill_names: list[str]) -> list[LoadedSkill]:
"""Load all skills specified in the config."""
def _normalize_runtime_field(raw: Any, skill_name: str) -> list[str]:
"""Normalize the optional `runtime` frontmatter field to a list[str].

Accepts: ["*"] (default), ["claude-code"], "claude-code" (string sugar),
or absent (-> ["*"]). Anything else logs a warning and falls back to
universal so a malformed manifest doesn't silently filter the skill.
"""
if raw is None:
return ["*"]
if isinstance(raw, str):
return [raw]
if isinstance(raw, list) and all(isinstance(x, str) for x in raw):
return raw or ["*"]
logger.warning(
"SKILL.md for '%s' has invalid `runtime` field %r; treating as universal",
skill_name, raw,
)
return ["*"]


def load_skills(
config_path: str,
skill_names: list[str],
current_runtime: str | None = None,
) -> list[LoadedSkill]:
"""Load all skills specified in the config.

If ``current_runtime`` is provided, skills whose ``runtime`` frontmatter
list does not include ``"*"`` or ``current_runtime`` are skipped (with a
log line) instead of being loaded — matches hermes' declarative compat
model so adapter-specific skills don't get force-loaded into runtimes
that can't actually execute their tools.
"""
skills_dir = Path(config_path) / "skills"
loaded = []

Expand Down Expand Up @@ -171,12 +208,21 @@ def load_skills(config_path: str, skill_names: list[str]) -> list[LoadedSkill]:

frontmatter, instructions = parse_skill_frontmatter(skill_md)

runtime_compat = _normalize_runtime_field(frontmatter.get("runtime"), skill_name)
if current_runtime is not None and "*" not in runtime_compat and current_runtime not in runtime_compat:
logger.info(
"Skipping skill '%s': runtime=%s not compatible with current=%s",
skill_name, runtime_compat, current_runtime,
)
continue

metadata = SkillMetadata(
id=skill_name,
name=frontmatter.get("name", skill_name),
description=frontmatter.get("description", ""),
tags=frontmatter.get("tags", []),
examples=frontmatter.get("examples", []),
runtime=runtime_compat,
)

# Executables live under scripts/ per the agentskills.io spec.
Expand Down
4 changes: 3 additions & 1 deletion workspace/skill_loader/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ def __init__(
config_path: str,
skill_names: list[str],
on_reload: Callable | None = None,
current_runtime: str | None = None,
) -> None:
self.config_path = config_path
self.skill_names = list(skill_names)
self.on_reload = on_reload
self.current_runtime = current_runtime
self._hashes: dict[str, str] = {} # rel_path → sha256 hex
self._running = False

Expand Down Expand Up @@ -169,7 +171,7 @@ async def _reload_skill(self, skill_name: str, changed_files: list[str]) -> None

try:
from skill_loader.loader import load_skills
loaded = load_skills(self.config_path, [skill_name])
loaded = load_skills(self.config_path, [skill_name], current_runtime=self.current_runtime)

if loaded:
skill = loaded[0]
Expand Down
88 changes: 88 additions & 0 deletions workspace/tests/test_skills_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,3 +641,91 @@ class FakeSkillSecurityError(Exception):
assert scan_kwargs[0]["fail_open"] is False, (
"fail_open_if_no_scanner=False from config must be forwarded to scan_skill_dependencies"
)


# ---------------------------------------------------------------------------
# Per-skill runtime compatibility (#119)
# ---------------------------------------------------------------------------
# A skill manifest can declare `runtime: [claude-code]` to opt out of being
# loaded into incompatible adapters. Default is universal — this is the
# important contract: existing skill libraries do NOT need to be migrated
# and continue to load into every adapter.


def _write_skill(tmp_path, name: str, runtime_block: str = "") -> None:
skill_dir = tmp_path / "skills" / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name.title()}\ndescription: x\n{runtime_block}---\n"
f"Body for {name}."
)


def test_skill_metadata_runtime_default_universal():
meta = SkillMetadata(id="t", name="T", description="d")
assert meta.runtime == ["*"], "default runtime must be universal — no implicit filtering"


def test_load_skills_no_runtime_field_is_universal(tmp_path):
"""Skills without a `runtime` frontmatter field load into any adapter."""
_write_skill(tmp_path, "legacy") # no runtime block
from unittest.mock import patch
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["legacy"], current_runtime="hermes")
assert len(loaded) == 1
assert loaded[0].metadata.runtime == ["*"]


def test_load_skills_explicit_match_loads(tmp_path):
_write_skill(tmp_path, "claude-only", "runtime:\n - claude-code\n")
from unittest.mock import patch
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["claude-only"], current_runtime="claude-code")
assert len(loaded) == 1


def test_load_skills_explicit_mismatch_skips(tmp_path):
_write_skill(tmp_path, "claude-only", "runtime:\n - claude-code\n")
from unittest.mock import patch
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["claude-only"], current_runtime="hermes")
assert loaded == [], "skill must be filtered out of incompatible runtime"


def test_load_skills_runtime_string_sugar(tmp_path):
"""Bare string `runtime: claude-code` is normalized to ['claude-code']."""
_write_skill(tmp_path, "sugary", "runtime: claude-code\n")
from unittest.mock import patch
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["sugary"], current_runtime="claude-code")
assert len(loaded) == 1
assert loaded[0].metadata.runtime == ["claude-code"]


def test_load_skills_runtime_wildcard_matches_anything(tmp_path):
_write_skill(tmp_path, "wild", "runtime:\n - '*'\n - claude-code\n")
from unittest.mock import patch
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["wild"], current_runtime="hermes")
assert len(loaded) == 1, "wildcard must short-circuit the runtime check"


def test_load_skills_no_current_runtime_loads_everything(tmp_path):
"""When current_runtime is None (test/fallback), no filtering happens."""
_write_skill(tmp_path, "claude-only", "runtime:\n - claude-code\n")
from unittest.mock import patch
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["claude-only"])
assert len(loaded) == 1, "absent current_runtime must preserve old behavior"


def test_load_skills_malformed_runtime_treated_as_universal(tmp_path, caplog):
"""A garbage runtime value warns + falls back to universal — never silently drops the skill."""
_write_skill(tmp_path, "garbage", "runtime: 123\n")
from unittest.mock import patch
import logging
with caplog.at_level(logging.WARNING, logger="skill_loader.loader"):
with patch("skill_loader.loader.load_skill_tools", return_value=[]):
loaded = load_skills(str(tmp_path), ["garbage"], current_runtime="hermes")
assert len(loaded) == 1, "malformed runtime must not silently filter"
assert any("invalid `runtime`" in r.message for r in caplog.records)
16 changes: 8 additions & 8 deletions workspace/tests/test_skills_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ async def _on_reload(skill):
tools=[],
)

def fake_load_skills(config_path, skill_names):
def fake_load_skills(config_path, skill_names, **kwargs):
return [fake_skill]

monkeypatch.setattr(_watcher_mod, "_load_skills_impl",
Expand Down Expand Up @@ -218,7 +218,7 @@ def _sync_on_reload(skill):
)

skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

await w._reload_skill("sk2", ["sk2/SKILL.md"])
Expand All @@ -245,7 +245,7 @@ async def test_emits_audit_event_on_success(self, tmp_path, monkeypatch):
tools=[],
)
skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

await w._reload_skill("audited", ["audited/SKILL.md"])
Expand Down Expand Up @@ -317,7 +317,7 @@ async def _on_reload(skill):
tools=[],
)
skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

_watcher_mod.POLL_INTERVAL = 0.01
Expand Down Expand Up @@ -378,7 +378,7 @@ async def test_stale_skill_tool_modules_are_evicted(self, tmp_path, monkeypatch)
tools=[],
)
skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

w = SkillsWatcher(str(tmp_path), ["sk"])
Expand All @@ -401,7 +401,7 @@ async def test_audit_import_error_suppressed_on_success(self, tmp_path, monkeypa
tools=[],
)
skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

# Make tools.audit.log_event raise an exception
Expand Down Expand Up @@ -429,7 +429,7 @@ async def test_on_reload_sync_callback_exception_is_logged_not_raised(
tools=[],
)
skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

def failing_callback(skill):
Expand All @@ -451,7 +451,7 @@ async def test_on_reload_async_callback_exception_is_logged_not_raised(
tools=[],
)
skills_mod = ModuleType("skill_loader.loader")
skills_mod.load_skills = lambda cp, names: [fake_skill]
skills_mod.load_skills = lambda cp, names, **_: [fake_skill]
monkeypatch.setitem(sys.modules, "skill_loader.loader", skills_mod)

async def failing_async_callback(skill):
Expand Down
Loading