| """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]+") |
| |
| _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") |
| _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") |
|
|
| |
| |
| |
| _SKILL_TEMPLATE_RE = re.compile(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}") |
|
|
| |
| |
| _INLINE_SHELL_RE = re.compile(r"!`([^`\n]+)`") |
|
|
| |
| _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 |
| |
| |
| |
| |
| 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, |
| ) |
|
|
| |
| 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 |
|
|
|
|
| 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 "") |
|
|
| |
| |
| |
| 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()] |
|
|
| |
| |
| 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_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_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="<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, iter_skill_index_files |
| disabled = _get_disabled_skill_names() |
| seen_names: set = set() |
|
|
| |
| 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 iter_skill_index_files(scan_dir, "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) |
| |
| if not skill_matches_platform(frontmatter): |
| continue |
| name = frontmatter.get('name', skill_md.parent.name) |
| if name in seen_names: |
| continue |
| |
| 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) |
| |
| |
| |
| 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 |
|
|