"""Lightweight skill metadata utilities shared by prompt_builder and skills_tool. This module intentionally avoids importing the tool registry, CLI config, or any heavy dependency chain. It is safe to import at module level without triggering tool registration or provider resolution. """ import logging import os import re 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, is_termux logger = logging.getLogger(__name__) # ── Platform mapping ────────────────────────────────────────────────────── PLATFORM_MAP = { "macos": "darwin", "linux": "linux", "windows": "win32", } EXCLUDED_SKILL_DIRS = frozenset( ( ".git", ".github", ".hub", ".archive", ".venv", "venv", "node_modules", "site-packages", "__pycache__", ".tox", ".nox", ".pytest_cache", ".mypy_cache", ".ruff_cache", ) ) def is_excluded_skill_path(path) -> bool: """True if any component of *path* is in EXCLUDED_SKILL_DIRS. Use this on every SKILL.md path produced by ``rglob`` to prune dependency, virtualenv, VCS, and cache directories. Centralising the check here keeps every skill-scanning site in sync with the shared exclusion set. Accepts a Path or string. """ try: parts = path.parts # Path except AttributeError: from pathlib import PurePath parts = PurePath(str(path)).parts return any(part in EXCLUDED_SKILL_DIRS for part in parts) # ── Lazy YAML loader ───────────────────────────────────────────────────── _yaml_load_fn = None def yaml_load(content: str): """Parse YAML with lazy import and CSafeLoader preference.""" global _yaml_load_fn if _yaml_load_fn is None: import yaml loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader def _load(value: str): return yaml.load(value, Loader=loader) _yaml_load_fn = _load return _yaml_load_fn(content) # ── Frontmatter parsing ────────────────────────────────────────────────── def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: """Parse YAML frontmatter from a markdown string. Uses yaml with CSafeLoader for full YAML support (nested metadata, lists) with a fallback to simple key:value splitting for robustness. Returns: (frontmatter_dict, remaining_body) """ frontmatter: Dict[str, Any] = {} body = content if not content.startswith("---"): return frontmatter, body end_match = re.search(r"\n---\s*\n", content[3:]) if not end_match: return frontmatter, body yaml_content = content[3 : end_match.start() + 3] body = content[end_match.end() + 3 :] try: parsed = yaml_load(yaml_content) if isinstance(parsed, dict): frontmatter = parsed except Exception: # Fallback: simple key:value parsing for malformed YAML for line in yaml_content.strip().split("\n"): if ":" not in line: continue key, value = line.split(":", 1) frontmatter[key.strip()] = value.strip() return frontmatter, body # ── Platform matching ───────────────────────────────────────────────────── def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: """Return True when the skill is compatible with the current OS. Skills declare platform requirements via a top-level ``platforms`` list in their YAML frontmatter:: platforms: [macos] # macOS only platforms: [macos, linux] # macOS and Linux 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: return True 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 # ── Environment matching ────────────────────────────────────────────────── # Recognized environment tags and how each is detected. An environment tag is # a *relevance* gate, not a hard-compatibility gate (that is what ``platforms:`` # is for). A skill tagged for an environment it isn't relevant to is hidden from # the skills index / offer surfaces so it does not add noise for users who will # never need it — but it can ALWAYS still be loaded explicitly (``skill_view``, # ``--skills``), because an explicit request is explicit consent. # # Detection is cached for the process lifetime via ``_ENV_DETECT_CACHE``. _KNOWN_ENVIRONMENTS = frozenset({"kanban", "docker", "s6"}) _ENV_DETECT_CACHE: Dict[str, bool] = {} def _detect_environment(env: str) -> bool: """Return True when the named runtime environment is currently active. Cached per process. Unknown env names return True (fail-open: never hide a skill because of a tag we don't understand). """ if env in _ENV_DETECT_CACHE: return _ENV_DETECT_CACHE[env] result = True if env == "kanban": # Kanban is "active" either as a dispatcher-spawned worker (the # dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the # worker env) or as an orchestrator profile that has opted into the # kanban toolset. Mirror the same signals the kanban tools themselves # gate on (``tools/kanban_tools.py``) so the offer filter agrees with # tool availability. if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"): result = True else: try: from tools.kanban_tools import _profile_has_kanban_toolset result = bool(_profile_has_kanban_toolset()) except Exception: result = False elif env == "docker": try: from hermes_constants import is_container result = is_container() except Exception: result = False elif env == "s6": # The Hermes Docker image runs s6-overlay as PID 1 (/init). s6 plants # its runtime scaffolding under /run/s6 and ships its admin tree under # /package/admin/s6-overlay. Either marker means we're inside an # s6-supervised container. result = os.path.isdir("/run/s6") or os.path.isdir( "/package/admin/s6-overlay" ) _ENV_DETECT_CACHE[env] = result return result def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool: """Return True when the skill is relevant to the current runtime environment. Skills may declare an ``environments`` list in their YAML frontmatter:: environments: [kanban] # only relevant when kanban is active environments: [s6] # only relevant inside the s6 Docker image environments: [docker] # only relevant inside any container If the field is absent or empty the skill is relevant in **all** environments (backward-compatible default). This is an OFFER-time filter: it controls whether a skill shows up in the skills index / autocomplete / slash-command list. It is intentionally NOT enforced by ``skill_view`` or ``--skills`` preloading — an explicit load is explicit consent, and load-bearing force-loads (e.g. the kanban dispatcher injecting ``--skills kanban-worker``) must always succeed regardless of how the offer surfaces filter the skill. A skill matches when ANY of its declared environments is currently active (OR semantics, mirroring ``platforms``). Unknown env tags fail open. """ environments = frontmatter.get("environments") if not environments: return True if not isinstance(environments, list): environments = [environments] for env in environments: normalized = str(env).lower().strip() if not normalized: continue if normalized not in _KNOWN_ENVIRONMENTS: # Tag we don't understand — don't hide the skill over it. return True if _detect_environment(normalized): return True return False # ── Disabled skills ─────────────────────────────────────────────────────── def get_disabled_skill_names(platform: str | None = None) -> Set[str]: """Read disabled skill names from config.yaml. Args: platform: Explicit platform name (e.g. ``"telegram"``). When *None*, resolves from ``HERMES_PLATFORM`` or ``HERMES_SESSION_PLATFORM`` env vars. Falls back to the global disabled list when no platform is determined. Reads the config file directly (no CLI config imports) to stay lightweight. """ config_path = get_config_path() if not config_path.exists(): return set() try: parsed = yaml_load(config_path.read_text(encoding="utf-8")) except Exception as e: logger.debug("Could not read skill config %s: %s", config_path, e) return set() if not isinstance(parsed, dict): return set() skills_cfg = parsed.get("skills") if not isinstance(skills_cfg, dict): return set() from gateway.session_context import get_session_env resolved_platform = ( platform or os.getenv("HERMES_PLATFORM") or get_session_env("HERMES_SESSION_PLATFORM") ) if resolved_platform: platform_disabled = (skills_cfg.get("platform_disabled") or {}).get( resolved_platform ) if platform_disabled is not None: return _normalize_string_set(platform_disabled) return _normalize_string_set(skills_cfg.get("disabled")) def _normalize_string_set(values) -> Set[str]: if values is None: return set() if isinstance(values, str): values = [values] return {str(v).strip() for v in values if str(v).strip()} # ── External skills directories ────────────────────────────────────────── # (config_path_str, mtime_ns) -> resolved external dirs list. Keyed by # mtime_ns so a config.yaml edit mid-run is picked up automatically; # otherwise every call would re-read + re-YAML-parse the 15KB config, # which becomes the dominant cost of ``hermes`` startup when ~120 skills # each trigger a category lookup during banner construction (10+ seconds # of pure waste). _EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {} def _external_dirs_cache_clear() -> None: """Test hook — drop the in-process cache.""" _EXTERNAL_DIRS_CACHE.clear() def get_external_skills_dirs() -> List[Path]: """Read ``skills.external_dirs`` from config.yaml and return validated paths. Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute path. Only directories that actually exist are returned. Duplicates and paths that resolve to the local ``~/.hermes/skills/`` are silently skipped. Cached in-process, keyed on ``config.yaml`` mtime — the function is called once per skill during banner / tool-registry scans, and YAML parsing a non-trivial config dominates ``hermes`` cold-start time when the cache is absent. """ config_path = get_config_path() if not config_path.exists(): return [] # Cache key: (absolute path, mtime_ns). stat() is ~2us vs ~85ms for # the full YAML parse, so the fast path is nearly free. try: stat = config_path.stat() cache_key: Tuple[str, int] = (str(config_path), stat.st_mtime_ns) except OSError: cache_key = None # type: ignore[assignment] if cache_key is not None: cached = _EXTERNAL_DIRS_CACHE.get(cache_key) if cached is not None: # Return a copy so callers can't mutate the cached list. return list(cached) try: parsed = yaml_load(config_path.read_text(encoding="utf-8")) except Exception: return [] if not isinstance(parsed, dict): return [] skills_cfg = parsed.get("skills") if not isinstance(skills_cfg, dict): return [] raw_dirs = skills_cfg.get("external_dirs") if not raw_dirs: result: List[Path] = [] if cache_key is not None: _EXTERNAL_DIRS_CACHE[cache_key] = list(result) return result if isinstance(raw_dirs, str): raw_dirs = [raw_dirs] if not isinstance(raw_dirs, list): return [] from hermes_constants import get_hermes_home hermes_home = get_hermes_home() local_skills = get_skills_dir().resolve() seen: Set[Path] = set() result = [] for entry in raw_dirs: entry = str(entry).strip() if not entry: continue # Expand ~ and environment variables expanded = os.path.expanduser(os.path.expandvars(entry)) p = Path(expanded) # Resolve relative paths against HERMES_HOME, not cwd if not p.is_absolute(): p = (hermes_home / p).resolve() else: p = p.resolve() if p == local_skills: continue if p in seen: continue if p.is_dir(): seen.add(p) result.append(p) else: logger.debug("External skills dir does not exist, skipping: %s", p) if cache_key is not None: _EXTERNAL_DIRS_CACHE[cache_key] = list(result) return result def get_all_skills_dirs() -> List[Path]: """Return all skill directories: local ``~/.hermes/skills/`` first, then external. The local dir is always first (and always included even if it doesn't exist yet — callers handle that). External dirs follow in config order. """ dirs = [get_skills_dir()] dirs.extend(get_external_skills_dirs()) return dirs # ── Condition extraction ────────────────────────────────────────────────── def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]: """Extract conditional activation fields from parsed frontmatter.""" metadata = frontmatter.get("metadata") # Handle cases where metadata is not a dict (e.g., a string from malformed YAML) if not isinstance(metadata, dict): metadata = {} hermes = metadata.get("hermes") or {} if not isinstance(hermes, dict): hermes = {} return { "fallback_for_toolsets": hermes.get("fallback_for_toolsets", []), "requires_toolsets": hermes.get("requires_toolsets", []), "fallback_for_tools": hermes.get("fallback_for_tools", []), "requires_tools": hermes.get("requires_tools", []), } # ── Skill config extraction ─────────────────────────────────────────────── def extract_skill_config_vars(frontmatter: Dict[str, Any]) -> List[Dict[str, Any]]: """Extract config variable declarations from parsed frontmatter. Skills declare config.yaml settings they need via:: metadata: hermes: config: - key: wiki.path description: Path to the LLM Wiki knowledge base directory default: "~/wiki" prompt: Wiki directory path Returns a list of dicts with keys: ``key``, ``description``, ``default``, ``prompt``. Invalid or incomplete entries are silently skipped. """ metadata = frontmatter.get("metadata") if not isinstance(metadata, dict): return [] hermes = metadata.get("hermes") if not isinstance(hermes, dict): return [] raw = hermes.get("config") if not raw: return [] if isinstance(raw, dict): raw = [raw] if not isinstance(raw, list): return [] result: List[Dict[str, Any]] = [] seen: set = set() for item in raw: if not isinstance(item, dict): continue key = str(item.get("key", "")).strip() if not key or key in seen: continue # Must have at least key and description desc = str(item.get("description", "")).strip() if not desc: continue entry: Dict[str, Any] = { "key": key, "description": desc, } default = item.get("default") if default is not None: entry["default"] = default prompt_text = item.get("prompt") if isinstance(prompt_text, str) and prompt_text.strip(): entry["prompt"] = prompt_text.strip() else: entry["prompt"] = desc seen.add(key) result.append(entry) return result def discover_all_skill_config_vars() -> List[Dict[str, Any]]: """Scan all enabled skills and collect their config variable declarations. Walks every skills directory, parses each SKILL.md frontmatter, and returns a deduplicated list of config var dicts. Each dict also includes a ``skill`` key with the skill name for attribution. Disabled and platform-incompatible skills are excluded. """ all_vars: List[Dict[str, Any]] = [] seen_keys: set = set() disabled = get_disabled_skill_names() for skills_dir in get_all_skills_dirs(): if not skills_dir.is_dir(): continue for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"): try: raw = skill_file.read_text(encoding="utf-8") frontmatter, _ = parse_frontmatter(raw) except Exception: continue skill_name = frontmatter.get("name") or skill_file.parent.name if str(skill_name) in disabled: continue if not skill_matches_platform(frontmatter): continue config_vars = extract_skill_config_vars(frontmatter) for var in config_vars: if var["key"] not in seen_keys: var["skill"] = str(skill_name) all_vars.append(var) seen_keys.add(var["key"]) return all_vars # Storage prefix: all skill config vars are stored under skills.config.* # in config.yaml. Skill authors declare logical keys (e.g. "wiki.path"); # the system adds this prefix for storage and strips it for display. SKILL_CONFIG_PREFIX = "skills.config" def _resolve_dotpath(config: Dict[str, Any], dotted_key: str): """Walk a nested dict following a dotted key. Returns None if any part is missing.""" parts = dotted_key.split(".") current = config for part in parts: if isinstance(current, dict) and part in current: current = current[part] else: return None return current def resolve_skill_config_values( config_vars: List[Dict[str, Any]], ) -> Dict[str, Any]: """Resolve current values for skill config vars from config.yaml. Skill config is stored under ``skills.config.`` in config.yaml. Returns a dict mapping **logical** keys (as declared by skills) to their current values (or the declared default if the key isn't set). Path values are expanded via ``os.path.expanduser``. """ config_path = get_config_path() config: Dict[str, Any] = {} if config_path.exists(): try: parsed = yaml_load(config_path.read_text(encoding="utf-8")) if isinstance(parsed, dict): config = parsed except Exception: pass resolved: Dict[str, Any] = {} for var in config_vars: logical_key = var["key"] storage_key = f"{SKILL_CONFIG_PREFIX}.{logical_key}" value = _resolve_dotpath(config, storage_key) if value is None or (isinstance(value, str) and not value.strip()): value = var.get("default", "") # Expand ~ in path-like values if isinstance(value, str) and ("~" in value or "${" in value): value = os.path.expanduser(os.path.expandvars(value)) resolved[logical_key] = value return resolved # ── Description extraction ──────────────────────────────────────────────── def extract_skill_description(frontmatter: Dict[str, Any]) -> str: """Extract a truncated description from parsed frontmatter.""" raw_desc = frontmatter.get("description", "") if not raw_desc: return "" desc = str(raw_desc).strip().strip("'\"") if len(desc) > 60: return desc[:57] + "..." return desc # ── File iteration ──────────────────────────────────────────────────────── def iter_skill_index_files(skills_dir: Path, filename: str): """Walk skills_dir yielding sorted paths matching *filename*. Excludes Hermes metadata, VCS, virtualenv/dependency, and cache directories so dependencies cannot register nested skills. """ matches = [] for root, dirs, files in os.walk(skills_dir, followlinks=True): dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS] if filename in files: matches.append(Path(root) / filename) for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): yield path # ── Namespace helpers for plugin-provided skills ─────────────────────────── _NAMESPACE_RE = re.compile(r"^[a-zA-Z0-9_-]+$") def parse_qualified_name(name: str) -> Tuple[Optional[str], str]: """Split ``'namespace:skill-name'`` into ``(namespace, bare_name)``. Returns ``(None, name)`` when there is no ``':'``. """ if ":" not in name: return None, name return tuple(name.split(":", 1)) # type: ignore[return-value] def is_valid_namespace(candidate: Optional[str]) -> bool: """Check whether *candidate* is a valid namespace (``[a-zA-Z0-9_-]+``).""" if not candidate: return False return bool(_NAMESPACE_RE.match(candidate))