"""Shared slash command helpers for skills and built-in prompt-style modes. Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces can invoke skills via /skill-name commands and prompt-only built-ins like /plan. """ import json import logging import re import subprocess from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} _PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+") # Patterns for sanitizing skill names into clean hyphen-separated slugs. _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") # Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md. # Tokens that don't resolve (e.g. ${HERMES_SESSION_ID} with no session) are # left as-is so the user can debug them. _SKILL_TEMPLATE_RE = re.compile(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}") # Matches inline shell snippets like: !`date +%Y-%m-%d` # Non-greedy, single-line only — no newlines inside the backticks. _INLINE_SHELL_RE = re.compile(r"!`([^`\n]+)`") # Cap inline-shell output so a runaway command can't blow out the context. _INLINE_SHELL_MAX_OUTPUT = 4000 def _load_skills_config() -> dict: """Load the ``skills`` section of config.yaml (best-effort).""" try: from hermes_cli.config import load_config cfg = load_config() or {} skills_cfg = cfg.get("skills") if isinstance(skills_cfg, dict): return skills_cfg except Exception: logger.debug("Could not read skills config", exc_info=True) return {} def _substitute_template_vars( content: str, skill_dir: Path | None, session_id: str | None, ) -> str: """Replace ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} in skill content. Only substitutes tokens for which a concrete value is available — unresolved tokens are left in place so the author can spot them. """ if not content: return content skill_dir_str = str(skill_dir) if skill_dir else None def _replace(match: re.Match) -> str: token = match.group(1) if token == "HERMES_SKILL_DIR" and skill_dir_str: return skill_dir_str if token == "HERMES_SESSION_ID" and session_id: return str(session_id) return match.group(0) return _SKILL_TEMPLATE_RE.sub(_replace, content) def _run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: """Execute a single inline-shell snippet and return its stdout (trimmed). Failures return a short ``[inline-shell error: ...]`` marker instead of raising, so one bad snippet can't wreck the whole skill message. """ try: completed = subprocess.run( ["bash", "-c", command], cwd=str(cwd) if cwd else None, capture_output=True, text=True, timeout=max(1, int(timeout)), check=False, ) except subprocess.TimeoutExpired: return f"[inline-shell timeout after {timeout}s: {command}]" except FileNotFoundError: return f"[inline-shell error: bash not found]" except Exception as exc: return f"[inline-shell error: {exc}]" output = (completed.stdout or "").rstrip("\n") if not output and completed.stderr: output = completed.stderr.rstrip("\n") if len(output) > _INLINE_SHELL_MAX_OUTPUT: output = output[:_INLINE_SHELL_MAX_OUTPUT] + "…[truncated]" return output def _expand_inline_shell( content: str, skill_dir: Path | None, timeout: int, ) -> str: """Replace every !`cmd` snippet in ``content`` with its stdout. Runs each snippet with the skill directory as CWD so relative paths in the snippet work the way the author expects. """ if "!`" not in content: return content def _replace(match: re.Match) -> str: cmd = match.group(1).strip() if not cmd: return "" return _run_inline_shell(cmd, skill_dir, timeout) return _INLINE_SHELL_RE.sub(_replace, content) def build_plan_path( user_instruction: str = "", *, now: datetime | None = None, ) -> Path: """Return the default workspace-relative markdown path for a /plan invocation. Relative paths are intentional: file tools are task/backend-aware and resolve them against the active working directory for local, docker, ssh, modal, daytona, and similar terminal backends. That keeps the plan with the active workspace instead of the Hermes host's global home directory. """ slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else "" slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-") if slug: slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-") slug = slug or "conversation-plan" timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S") return Path(".hermes") / "plans" / f"{timestamp}-{slug}.md" def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None: """Load a skill by name/path and return (loaded_payload, skill_dir, display_name).""" raw_identifier = (skill_identifier or "").strip() if not raw_identifier: return None try: from tools.skills_tool import SKILLS_DIR, skill_view identifier_path = Path(raw_identifier).expanduser() if identifier_path.is_absolute(): try: normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) except Exception: normalized = raw_identifier else: normalized = raw_identifier.lstrip("/") loaded_skill = json.loads(skill_view(normalized, task_id=task_id)) except Exception: return None if not loaded_skill.get("success"): return None skill_name = str(loaded_skill.get("name") or normalized) skill_path = str(loaded_skill.get("path") or "") skill_dir = None # Prefer the absolute skill_dir returned by skill_view() — this is # correct for both local and external skills. Fall back to the old # SKILLS_DIR-relative reconstruction only when skill_dir is absent # (e.g. legacy skill_view responses). abs_skill_dir = loaded_skill.get("skill_dir") if abs_skill_dir: skill_dir = Path(abs_skill_dir) elif skill_path: try: skill_dir = SKILLS_DIR / Path(skill_path).parent except Exception: skill_dir = None return loaded_skill, skill_dir, skill_name def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None: """Resolve and inject skill-declared config values into the message parts. If the loaded skill's frontmatter declares ``metadata.hermes.config`` entries, their current values (from config.yaml or defaults) are appended as a ``[Skill config: ...]`` block so the agent knows the configured values without needing to read config.yaml itself. """ try: from agent.skill_utils import ( extract_skill_config_vars, parse_frontmatter, resolve_skill_config_values, ) # The loaded_skill dict contains the raw content which includes frontmatter raw_content = str(loaded_skill.get("raw_content") or loaded_skill.get("content") or "") if not raw_content: return frontmatter, _ = parse_frontmatter(raw_content) config_vars = extract_skill_config_vars(frontmatter) if not config_vars: return resolved = resolve_skill_config_values(config_vars) if not resolved: return lines = ["", f"[Skill config (from {display_hermes_home()}/config.yaml):"] for key, value in resolved.items(): display_val = str(value) if value else "(not set)" lines.append(f" {key} = {display_val}") lines.append("]") parts.extend(lines) except Exception: pass # Non-critical — skill still loads without config injection def _build_skill_message( loaded_skill: dict[str, Any], skill_dir: Path | None, activation_note: str, user_instruction: str = "", runtime_note: str = "", session_id: str | None = None, ) -> str: """Format a loaded skill into a user/system message payload.""" from tools.skills_tool import SKILLS_DIR content = str(loaded_skill.get("content") or "") # ── Template substitution and inline-shell expansion ── # Done before anything else so downstream blocks (setup notes, # supporting-file hints) see the expanded content. skills_cfg = _load_skills_config() if skills_cfg.get("template_vars", True): content = _substitute_template_vars(content, skill_dir, session_id) if skills_cfg.get("inline_shell", False): timeout = int(skills_cfg.get("inline_shell_timeout", 10) or 10) content = _expand_inline_shell(content, skill_dir, timeout) parts = [activation_note, "", content.strip()] # ── Inject the absolute skill directory so the agent can reference # bundled scripts without an extra skill_view() round-trip. ── if skill_dir: parts.append("") parts.append(f"[Skill directory: {skill_dir}]") parts.append( "Resolve any relative paths in this skill (e.g. `scripts/foo.js`, " "`templates/config.yaml`) against that directory, then run them " "with the terminal tool using the absolute path." ) # ── Inject resolved skill config values ── _inject_skill_config(loaded_skill, parts) if loaded_skill.get("setup_skipped"): parts.extend( [ "", "[Skill setup note: Required environment setup was skipped. Continue loading the skill and explain any reduced functionality if it matters.]", ] ) elif loaded_skill.get("gateway_setup_hint"): parts.extend( [ "", f"[Skill setup note: {loaded_skill['gateway_setup_hint']}]", ] ) elif loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"): parts.extend( [ "", f"[Skill setup note: {loaded_skill['setup_note']}]", ] ) supporting = [] linked_files = loaded_skill.get("linked_files") or {} for entries in linked_files.values(): if isinstance(entries, list): supporting.extend(entries) if not supporting and skill_dir: for subdir in ("references", "templates", "scripts", "assets"): subdir_path = skill_dir / subdir if subdir_path.exists(): for f in sorted(subdir_path.rglob("*")): if f.is_file() and not f.is_symlink(): rel = str(f.relative_to(skill_dir)) supporting.append(rel) if supporting and skill_dir: try: skill_view_target = str(skill_dir.relative_to(SKILLS_DIR)) except ValueError: # Skill is from an external dir — use the skill name instead skill_view_target = skill_dir.name parts.append("") parts.append("[This skill has supporting files:]") for sf in supporting: parts.append(f"- {sf} -> {skill_dir / sf}") parts.append( f'\nLoad any of these with skill_view(name="{skill_view_target}", ' f'file_path=""), or run scripts directly by absolute path ' f"(e.g. `node {skill_dir}/scripts/foo.js`)." ) if user_instruction: parts.append("") parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}") if runtime_note: parts.append("") parts.append(f"[Runtime note: {runtime_note}]") return "\n".join(parts) def scan_skill_commands() -> Dict[str, Dict[str, Any]]: """Scan ~/.hermes/skills/ and return a mapping of /command -> skill info. Returns: Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}. """ global _skill_commands _skill_commands = {} try: from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names from agent.skill_utils import get_external_skills_dirs disabled = _get_disabled_skill_names() seen_names: set = set() # Scan local dir first, then external dirs dirs_to_scan = [] if SKILLS_DIR.exists(): dirs_to_scan.append(SKILLS_DIR) dirs_to_scan.extend(get_external_skills_dirs()) for scan_dir in dirs_to_scan: for skill_md in scan_dir.rglob("SKILL.md"): if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): continue try: content = skill_md.read_text(encoding='utf-8') frontmatter, body = _parse_frontmatter(content) # Skip skills incompatible with the current OS platform if not skill_matches_platform(frontmatter): continue name = frontmatter.get('name', skill_md.parent.name) if name in seen_names: continue # Respect user's disabled skills config if name in disabled: continue description = frontmatter.get('description', '') if not description: for line in body.strip().split('\n'): line = line.strip() if line and not line.startswith('#'): description = line[:80] break seen_names.add(name) # Normalize to hyphen-separated slug, stripping # non-alnum chars (e.g. +, /) to avoid invalid # Telegram command names downstream. cmd_name = name.lower().replace(' ', '-').replace('_', '-') cmd_name = _SKILL_INVALID_CHARS.sub('', cmd_name) cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-') if not cmd_name: continue _skill_commands[f"/{cmd_name}"] = { "name": name, "description": description or f"Invoke the {name} skill", "skill_md_path": str(skill_md), "skill_dir": str(skill_md.parent), } except Exception: continue except Exception: pass return _skill_commands def get_skill_commands() -> Dict[str, Dict[str, Any]]: """Return the current skill commands mapping (scan first if empty).""" if not _skill_commands: scan_skill_commands() return _skill_commands def resolve_skill_command_key(command: str) -> Optional[str]: """Resolve a user-typed /command to its canonical skill_cmds key. Skills are always stored with hyphens — ``scan_skill_commands`` normalizes spaces and underscores to hyphens when building the key. Hyphens and underscores are treated interchangeably in user input: this matches ``_check_unavailable_skill`` and accommodates Telegram bot-command names (which disallow hyphens, so ``/claude-code`` is registered as ``/claude_code`` and comes back in the underscored form). Returns the matching ``/slug`` key from ``get_skill_commands()`` or ``None`` if no match. """ if not command: return None cmd_key = f"/{command.replace('_', '-')}" return cmd_key if cmd_key in get_skill_commands() else None def build_skill_invocation_message( cmd_key: str, user_instruction: str = "", task_id: str | None = None, runtime_note: str = "", ) -> Optional[str]: """Build the user message content for a skill slash command invocation. Args: cmd_key: The command key including leading slash (e.g., "/gif-search"). user_instruction: Optional text the user typed after the command. Returns: The formatted message string, or None if the skill wasn't found. """ commands = get_skill_commands() skill_info = commands.get(cmd_key) if not skill_info: return None loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id) if not loaded: return f"[Failed to load skill: {skill_info['name']}]" loaded_skill, skill_dir, skill_name = loaded activation_note = ( f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want ' "you to follow its instructions. The full skill content is loaded below.]" ) return _build_skill_message( loaded_skill, skill_dir, activation_note, user_instruction=user_instruction, runtime_note=runtime_note, session_id=task_id, ) def build_preloaded_skills_prompt( skill_identifiers: list[str], task_id: str | None = None, ) -> tuple[str, list[str], list[str]]: """Load one or more skills for session-wide CLI preloading. Returns (prompt_text, loaded_skill_names, missing_identifiers). """ prompt_parts: list[str] = [] loaded_names: list[str] = [] missing: list[str] = [] seen: set[str] = set() for raw_identifier in skill_identifiers: identifier = (raw_identifier or "").strip() if not identifier or identifier in seen: continue seen.add(identifier) loaded = _load_skill_payload(identifier, task_id=task_id) if not loaded: missing.append(identifier) continue loaded_skill, skill_dir, skill_name = loaded activation_note = ( f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill ' "preloaded. Treat its instructions as active guidance for the duration of this " "session unless the user overrides them.]" ) prompt_parts.append( _build_skill_message( loaded_skill, skill_dir, activation_note, session_id=task_id, ) ) loaded_names.append(skill_name) return "\n\n".join(prompt_parts), loaded_names, missing