diff --git a/workspace/adapter_base.py b/workspace/adapter_base.py index 6442ecc0..de20dbb1 100644 --- a/workspace/adapter_base.py +++ b/workspace/adapter_base.py @@ -437,15 +437,19 @@ class BaseAdapter(ABC): 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) diff --git a/workspace/main.py b/workspace/main.py index ab0b06f5..2dd9d4e4 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -323,6 +323,7 @@ async def main(): # pragma: no cover 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}") diff --git a/workspace/skill_loader/loader.py b/workspace/skill_loader/loader.py index 05333564..428d7600 100644 --- a/workspace/skill_loader/loader.py +++ b/workspace/skill_loader/loader.py @@ -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 @@ -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 = [] @@ -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. diff --git a/workspace/skill_loader/watcher.py b/workspace/skill_loader/watcher.py index 03b2372f..d9448278 100644 --- a/workspace/skill_loader/watcher.py +++ b/workspace/skill_loader/watcher.py @@ -67,10 +67,12 @@ class SkillsWatcher: 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 @@ -169,7 +171,7 @@ class SkillsWatcher: 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] diff --git a/workspace/tests/test_skills_loader.py b/workspace/tests/test_skills_loader.py index 7f2cae98..85cd80e9 100644 --- a/workspace/tests/test_skills_loader.py +++ b/workspace/tests/test_skills_loader.py @@ -641,3 +641,91 @@ def test_load_skills_fail_open_if_no_scanner_wiring(tmp_path, monkeypatch): 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)