diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 959a109a6cb..5b8e4c22a67 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -12,7 +12,7 @@ import sys from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple -from hermes_constants import get_config_path, get_skills_dir +from hermes_constants import get_config_path, get_skills_dir, is_termux logger = logging.getLogger(__name__) @@ -136,6 +136,14 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: If the field is absent or empty the skill is compatible with **all** platforms (backward-compatible default). + + Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on + older Pythons but became ``"android"`` on Python 3.13+. Termux is a + Linux userland riding on the Android kernel, so skills tagged + ``linux`` are treated as compatible in Termux regardless of which + ``sys.platform`` value Python reports. Individual Linux commands + inside a skill may still misbehave (no systemd, BusyBox utils, no + apt/dnf, etc.) but that is on the skill, not on platform gating. """ platforms = frontmatter.get("platforms") if not platforms: @@ -143,11 +151,21 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: if not isinstance(platforms, list): platforms = [platforms] current = sys.platform + running_in_termux = is_termux() for platform in platforms: normalized = str(platform).lower().strip() mapped = PLATFORM_MAP.get(normalized, normalized) if current.startswith(mapped): return True + # Termux runs a Linux userland on Android. Accept linux-tagged + # skills regardless of whether sys.platform is "linux" (pre-3.13 + # Termux) or "android" (Python 3.13+ Termux, and any other + # Android runtime). + if running_in_termux and mapped == "linux": + return True + # Explicit termux/android tags match a Termux session too. + if running_in_termux and mapped in ("termux", "android"): + return True return False diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index ae22dc569be..1338e7a5b24 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -1,6 +1,12 @@ """Tests for agent/skill_utils.py.""" -from agent.skill_utils import extract_skill_conditions, iter_skill_index_files +from unittest.mock import patch + +from agent.skill_utils import ( + extract_skill_conditions, + iter_skill_index_files, + skill_matches_platform, +) def test_metadata_as_dict_with_hermes(): @@ -94,3 +100,100 @@ def test_iter_skill_index_files_prunes_dependency_dirs(tmp_path): found = list(iter_skill_index_files(tmp_path, "SKILL.md")) assert found == [real / "SKILL.md"] + + +# ── skill_matches_platform on Termux ────────────────────────────────────── + + +class TestSkillMatchesPlatformTermux: + """Termux is Linux userland on Android. Skills tagged platforms:[linux] + must load there regardless of whether Python reports sys.platform as + "linux" (pre-3.13) or "android" (3.13+). Reported by user @LikiusInik + in May 2026 — only 3 built-in skills appeared on Termux because every + github/productivity/mlops skill is tagged platforms:[linux,macos,windows] + and sys.platform=="android" did not start with "linux". + """ + + def test_no_platforms_field_matches_everywhere(self): + # Backward-compat default — skills without a platforms tag load + # on any OS, Termux included. + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform({}) is True + assert skill_matches_platform({"name": "foo"}) is True + + def test_linux_skill_loads_on_termux_android_platform(self): + # Python 3.13+ on Termux reports sys.platform == "android". + fm = {"platforms": ["linux"]} + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform(fm) is True + + def test_linux_macos_windows_skill_loads_on_termux(self): + # The common "[linux, macos, windows]" tag used by github-*, + # productivity, mlops, etc. + fm = {"platforms": ["linux", "macos", "windows"]} + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform(fm) is True + + def test_linux_skill_loads_on_termux_linux_platform(self): + # Pre-3.13 Termux reports sys.platform == "linux" already — this + # works without the Termux escape hatch but must still pass. + fm = {"platforms": ["linux"]} + with patch("agent.skill_utils.sys.platform", "linux"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform(fm) is True + + def test_macos_only_skill_still_excluded_on_termux(self): + # macOS-only skills (apple-notes, imessage, ...) should NOT load + # on Termux. The Termux fallback only widens platforms:[linux,...]. + fm = {"platforms": ["macos"]} + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform(fm) is False + + def test_windows_only_skill_still_excluded_on_termux(self): + fm = {"platforms": ["windows"]} + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform(fm) is False + + def test_explicit_termux_or_android_tag_matches(self): + # Skills can also opt in explicitly via platforms:[termux] or + # platforms:[android] — both should match a Termux session. + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=True + ): + assert skill_matches_platform({"platforms": ["termux"]}) is True + assert skill_matches_platform({"platforms": ["android"]}) is True + + def test_non_termux_android_does_not_widen(self): + # If we're somehow on a plain Android Python (not Termux), don't + # silently load Linux skills — Termux is the supported environment. + fm = {"platforms": ["linux"]} + with patch("agent.skill_utils.sys.platform", "android"), patch( + "agent.skill_utils.is_termux", return_value=False + ): + assert skill_matches_platform(fm) is False + + def test_linux_skill_on_real_linux_unaffected(self): + # The non-Termux Linux path must not change. + fm = {"platforms": ["linux"]} + with patch("agent.skill_utils.sys.platform", "linux"), patch( + "agent.skill_utils.is_termux", return_value=False + ): + assert skill_matches_platform(fm) is True + + def test_macos_skill_on_real_macos_unaffected(self): + fm = {"platforms": ["macos"]} + with patch("agent.skill_utils.sys.platform", "darwin"), patch( + "agent.skill_utils.is_termux", return_value=False + ): + assert skill_matches_platform(fm) is True