Files
hermes-agent/hermes_cli/commands.py
Teknium 8eeb79aade refactor(cli/commands): extract completer + platform derivations into commands_completion/commands_platforms with lazy re-exports
- hermes_cli/commands.py keeps CommandDef, COMMAND_REGISTRY, derived lookups, gateway helpers;
  all moved names resolve via PEP 562 __getattr__ so imports and patch targets keep working
- completer: per-command _<x>_completions -> module functions sharing _prefix_completions /
  _split_args; static refs table; SlashCommandAutoSuggest shares _allowed/_history_suggestion
- platforms: telegram/discord/slack derivations, _nested_mapping + _dedupe_sanitized_names
  folded into _telegram_command_menu_config / _sanitized_rank
- COMMAND_REGISTRY + telegram/discord/slack derivations + completer output byte-identical vs base
2026-09-02 15:35:08 -07:00

630 lines
34 KiB
Python

"""Slash command definitions and autocomplete for the Hermes CLI.
Central registry for all slash commands. Every consumer -- CLI help, gateway
dispatch, Telegram BotCommands, Slack subcommand mapping, autocomplete --
derives its data from ``COMMAND_REGISTRY``.
To add a command: add a ``CommandDef`` entry to ``COMMAND_REGISTRY``.
To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Any
from utils import is_truthy_value
from hermes_constants import INDICATOR_STYLES
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# CommandDef dataclass
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class CommandDef:
"""Definition of a single slash command."""
name: str # canonical name without slash: "background"
description: str # human-readable description
category: str # "Session", "Configuration", etc.
aliases: tuple[str, ...] = () # alternative names: ("bg",)
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
subcommands: tuple[str, ...] = () # tab-completable subcommands
cli_only: bool = False # only available in CLI
gateway_only: bool = False # only available in gateway/messaging
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
# Mid-run (agent busy) gateway behavior, driving the Guard-2 dispatcher in
# gateway/run.py (_dispatch_busy_slash_command):
# "dispatch" — run while busy (normal handler, or the
# mid-run variant named by ``busy_handler``).
# "reject" — refuse mid-run; generic "Agent is running"
# catch-all unless ``busy_handler`` names a
# command-specific reject message.
# "interrupt_then_dispatch" — interrupt the agent first (/stop, /new,
# /reset); Guard 1 (platforms/base.py) routes
# these via is_interrupt_then_dispatch().
busy_policy: str = "reject"
# Key of a special mid-run handler in gateway/run.py's Guard-2 table for
# commands whose busy behavior differs from their normal handler.
busy_handler: str | None = None
# Key in ``hermes_cli.slash_exec.EXECUTORS`` — a pure formatter producing
# the surface-independent core text; surfaces apply only their decoration.
# A string key (not a callable) keeps this module import-light for the
# gateway (no prompt_toolkit, no executor dependencies).
execute: str | None = None
# Desktop composer: ``options`` | ``text`` | ``mixed``. ``None`` is inferred.
argument_mode: str | None = None
# Desktop availability. ``None`` = offered; ``hidden`` = runs but stays out
# of the popover; otherwise a reason (terminal / messaging / settings / …).
desktop: str | None = None
# Valid values for CommandDef.busy_policy (see field docs above).
VALID_BUSY_POLICIES: frozenset[str] = frozenset(
{"dispatch", "reject", "interrupt_then_dispatch"}
)
# ---------------------------------------------------------------------------
# Central registry -- single source of truth
# ---------------------------------------------------------------------------
COMMAND_REGISTRY: list[CommandDef] = [
# Session
CommandDef("start", "Acknowledge platform start pings without a reply", "Session",
gateway_only=True, busy_policy="dispatch", busy_handler="start"),
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
aliases=("reset",), args_hint="[name]",
busy_policy="interrupt_then_dispatch", busy_handler="new"),
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
gateway_only=True, args_hint="[off|help|session-id]"),
CommandDef("clear", "Clear screen and start a new session", "Session",
cli_only=True, desktop="terminal"),
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
cli_only=True, desktop="terminal"),
CommandDef("history", "Show conversation history", "Session",
cli_only=True, desktop="terminal"),
CommandDef("save", "Export the current conversation (bare /save shows usage)", "Session",
args_hint="<json|md|html> [filename] [redact]"),
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
CommandDef("prompt", "Compose your next prompt in $EDITOR (markdown), then send it", "Session",
cli_only=True, args_hint="[initial text]", aliases=("compose",)),
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
args_hint="[N]"),
CommandDef("title", "Set a title for the current session", "Session",
args_hint="[name]"),
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
args_hint="<platform>", cli_only=True, argument_mode="options"),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("worktree", "Show, list, create, or prune isolated git worktrees", "Session",
cli_only=True, args_hint="[new [name]|list|prune [--dry-run]]",
subcommands=("new", "list", "prune")),
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session",
aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"),
CommandDef("rollback", "List or restore filesystem checkpoints (restores keep your hand-edits; --all overrides)", "Session",
args_hint="[number] [--all]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]",
desktop="terminal"),
CommandDef("export", "Export a profile (config, skills, theme) to a shareable archive", "Configuration",
cli_only=True, args_hint="[profile] [-o output.tar.gz]"),
CommandDef("import", "Import a shared profile archive as a new profile", "Configuration",
cli_only=True, args_hint="<archive.tar.gz> [--name <name>]"),
CommandDef("stop", "Kill all running background processes", "Session",
busy_policy="interrupt_then_dispatch", busy_handler="stop"),
CommandDef("pause", "Pause new work globally (emergency stop); '/pause off' resumes", "Session",
gateway_only=True, args_hint="[reason | off]",
busy_policy="dispatch"),
CommandDef("approve", "Approve a pending dangerous command", "Session",
gateway_only=True, args_hint="[session|always]", busy_policy="dispatch",
desktop="messaging"),
CommandDef("deny", "Deny a pending dangerous command (optionally with a reason)", "Session",
gateway_only=True, args_hint="[all] [reason]", busy_policy="dispatch",
desktop="messaging"),
CommandDef("bg", "Run a prompt in a separate background session", "Session",
args_hint="<prompt>", busy_policy="dispatch"),
CommandDef("btw", "Ask a side question about the current conversation without interrupting it", "Session",
args_hint="<question>", busy_policy="dispatch"),
CommandDef("agents", "Show active agents and running tasks", "Session",
aliases=("tasks",), busy_policy="dispatch"),
CommandDef("journey", "Open the learning journey timeline",
"Session", aliases=("learning", "memory-graph"), cli_only=True,
args_hint="[list|delete <id>|edit <id>]",
subcommands=("list", "delete", "edit")),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
aliases=("q",), args_hint="<prompt>",
busy_policy="dispatch", busy_handler="queue"),
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
args_hint="<prompt>", busy_policy="dispatch", busy_handler="steer"),
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
args_hint="[text | draft <text> | show | gate add <cmd> | pause | resume | clear | status | wait <pid> | unwait]",
argument_mode="mixed", busy_policy="dispatch", busy_handler="goal"),
CommandDef("heartbeat", "Set a recurring prompt that re-enters this session when idle", "Session",
aliases=("hb",), args_hint="[every <interval> <prompt> | status | pause | resume | clear]",
subcommands=("status", "pause", "resume", "clear"),
busy_policy="dispatch"),
CommandDef("refine", "Review this conversation now and save lessons to memory/skills", "Session",
args_hint="[focus instructions]"),
CommandDef("review", "Spawn an independent subagent to review the work just discussed (PR, code, docs)", "Session",
args_hint="[review instructions]"),
CommandDef("loop", "Re-run a prompt on a recurring interval in this session", "Session",
aliases=("proactive",),
args_hint="[interval] <prompt> [--times N] [--until <condition>] | status | pause | resume | stop",
argument_mode="mixed", busy_policy="dispatch", busy_handler="loop"),
CommandDef("plan", "Write a markdown implementation plan to .hermes/plans/ without executing anything", "Session",
args_hint="[task]"),
CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session",
args_hint="<prompt>", busy_policy="reject", busy_handler="moa"),
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]", busy_policy="dispatch"),
CommandDef("status", "Show session, model, token, and context info", "Session",
busy_policy="dispatch"),
CommandDef("egress", "Show Docker egress proxy status", "Session",
args_hint="[status]", subcommands=("status",),
busy_policy="dispatch", busy_handler="egress",
execute="egress"),
CommandDef("context", "Show detailed context window view with usage gauge, category breakdown, compression stats, and throughput", "Session",
aliases=("ctx",), args_hint="[all]", subcommands=("all",),
busy_policy="dispatch"),
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
CommandDef("profile", "Show active profile name and home directory", "Info",
busy_policy="dispatch", execute="profile"),
CommandDef("sethome", "Set this chat as the home channel", "Session",
gateway_only=True, aliases=("set-home",), desktop="terminal"),
CommandDef("resume", "Resume a previously-named session", "Session",
args_hint="[name]", argument_mode="mixed"),
# Configuration
CommandDef("sessions", "Browse and resume previous sessions", "Session"),
# Configuration
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True, desktop="terminal"),
CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
args_hint="[model] [--provider name] [--global|--session] [--refresh]",
busy_policy="reject", busy_handler="model", desktop="hidden"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]",
busy_policy="reject", busy_handler="codex-runtime"),
CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]", argument_mode="options"),
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
cli_only=True, aliases=("sb",), desktop="terminal"),
CommandDef("battery", "Toggle a color-coded battery indicator in the status bar",
"Configuration", cli_only=True, args_hint="[on|off|status]",
subcommands=("on", "off", "status")),
CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration",
cli_only=True, args_hint="[on|off|status]",
subcommands=("on", "off", "status"), aliases=("ts",)),
CommandDef("diff", "Show git changes in the working directory", "Info",
args_hint="[staged|all|session] [--stat] [path...]",
subcommands=("staged", "all", "session")),
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
"Configuration", cli_only=True,
gateway_config_gate="display.tool_progress_command",
busy_policy="dispatch", desktop="terminal"),
CommandDef("focus", "Toggle focus view — show only your prompt and the final response",
"Configuration", cli_only=True, args_hint="[on|off|status]",
subcommands=("on", "off", "status")),
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
"Configuration", args_hint="[on|off|status]",
subcommands=("on", "off", "status"), busy_policy="dispatch",
desktop="terminal"),
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
"Configuration", busy_policy="dispatch"),
CommandDef("approvals", "Show or set the persistent dangerous-command approval mode",
"Configuration", args_hint="[manual|smart|off]",
subcommands=("manual", "smart", "off")),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
args_hint="[level|show|hide|full|clamp] [--global]",
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global"),
desktop="advanced"),
CommandDef("fast", "Fast mode — OpenAI Priority Processing / Anthropic Fast Mode (normal/fast/auto/cold)", "Configuration",
args_hint="[normal|fast|auto|cold|status] [--global]",
subcommands=("normal", "fast", "auto", "cold", "status", "on", "off", "--global"),
desktop="advanced"),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]", argument_mode="options"),
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
cli_only=True, args_hint=f"[{'|'.join(INDICATOR_STYLES)}]",
subcommands=INDICATOR_STYLES, desktop="terminal"),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status"),
desktop="composer-voice"),
CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration",
cli_only=True, args_hint="[on|off|status]",
subcommands=("on", "off", "status")),
CommandDef("busy", "Control how messages behave while Hermes is working", "Configuration",
args_hint="[queue|steer|interrupt|status]",
subcommands=("queue", "steer", "interrupt", "status"),
busy_policy="dispatch", desktop="terminal"),
# Tools & Skills
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
args_hint="[list|disable|enable] [name...]", cli_only=True,
argument_mode="options"),
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
cli_only=True, desktop="terminal"),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
gateway_config_gate="skills.write_approval",
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "approval"),
desktop="settings"),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"Tools & Skills",
args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills", execute="bundles"),
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
CommandDef("hatch", "Generate a new petdex pet from a description",
"Tools & Skills", cli_only=True, aliases=("generate-pet",), args_hint="[description]"),
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
"Tools & Skills", args_hint="<what to learn from>"),
CommandDef("init", "Generate or update AGENTS.md project instructions from a repo scan",
"Tools & Skills", args_hint="[notes]"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove"),
desktop="terminal"),
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
subcommands=("accept", "dismiss", "catalog", "clear")),
CommandDef("blueprint", "Set up an automation from a blueprint template",
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
"Tools & Skills", args_hint="[subcommand]",
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived"),
desktop="advanced"),
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
"Tools & Skills", args_hint="[subcommand]",
subcommands=("init", "boards", "create", "list", "ls", "show", "assign",
"reclaim", "reassign", "diagnostics", "diag", "link", "unlink",
"claim", "comment", "complete", "edit", "block", "unblock",
"archive", "tail", "dispatch", "stats", "notify-subscribe",
"notify-list", "notify-unsubscribe", "log", "runs",
"heartbeat", "assignees", "context", "specify", "gc"),
busy_policy="dispatch", desktop="advanced"),
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
cli_only=True, desktop="terminal"),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",), desktop="advanced"),
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
"Tools & Skills", aliases=("reload_skills",), desktop="advanced"),
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP, or switch to Browser Use mode", "Tools & Skills",
cli_only=True, args_hint="[connect|disconnect|status|use]",
subcommands=("connect", "disconnect", "status", "use")),
CommandDef("plugins", "List installed plugins and their status",
"Tools & Skills", cli_only=True, desktop="terminal"),
# Info
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
gateway_only=True, args_hint="[page]", busy_policy="dispatch",
execute="gateway_commands"),
CommandDef("help", "Show available commands (/help skills lists skill commands, /help <text> filters)", "Info", busy_policy="dispatch",
execute="gateway_help", args_hint="[skills|<filter>]"),
CommandDef("palette", "Open the fuzzy command palette (also Ctrl+P)", "Info",
cli_only=True, busy_policy="dispatch"),
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
gateway_only=True, busy_policy="dispatch", desktop="terminal"),
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
args_hint="[reset [--force]]"),
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
cli_only=True, aliases=("upgrade",)),
CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]", desktop="advanced"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
cli_only=True, aliases=("gateway",), desktop="terminal"),
CommandDef("platform", "Pause, resume, or list a failing gateway platform", "Info",
gateway_only=True, args_hint="<pause|resume|list> [name]"),
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
cli_only=True, args_hint="[number]", desktop="terminal"),
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
cli_only=True, desktop="terminal"),
CommandDef("image", "Attach a local image file for your next prompt", "Info",
cli_only=True, args_hint="<path>", desktop="terminal"),
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
busy_policy="dispatch", desktop="terminal"),
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",),
busy_policy="dispatch", execute="version"),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info",
args_hint="[nous|local]"),
# Exit
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",
cli_only=True, aliases=("exit",), args_hint="[--delete]",
desktop="terminal"),
]
# Used only to distinguish ``mixed`` (subcommands plus free-text) from
# ``options`` (subcommand list only). A bare ``args_hint`` with no
# subcommands is always ``text`` — do not add tokens here for that path.
_PROSE_HINTS = ("<prompt>", "[text", "instructions", "[interval]", "<what")
def infer_argument_mode(cmd: CommandDef) -> str | None:
"""Composer mode: explicit on the CommandDef, else inferred from its args."""
if cmd.argument_mode in {"options", "text", "mixed"}:
return cmd.argument_mode
hint = (cmd.args_hint or "").strip()
if cmd.subcommands and hint and any(token in hint.lower() for token in _PROSE_HINTS):
return "mixed"
if cmd.subcommands:
return "options"
if hint:
return "text"
return None
def command_desktop_meta(cmd: CommandDef) -> dict[str, str | None]:
"""Wire shape for ``commands.catalog`` — reads the CommandDef, nothing else."""
return {"argument_mode": infer_argument_mode(cmd), "desktop": cmd.desktop}
# ---------------------------------------------------------------------------
# Derived lookups -- rebuilt once at import time, refreshed by rebuild_lookups()
# ---------------------------------------------------------------------------
def _build_command_lookup() -> dict[str, CommandDef]:
"""Map every name and alias to its CommandDef."""
lookup: dict[str, CommandDef] = {}
for cmd in COMMAND_REGISTRY:
lookup[cmd.name] = cmd
for alias in cmd.aliases:
lookup[alias] = cmd
return lookup
_COMMAND_LOOKUP: dict[str, CommandDef] = _build_command_lookup()
def resolve_command(name: str) -> CommandDef | None:
"""Resolve a command name or alias to its CommandDef.
Accepts names with or without the leading slash.
"""
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
def _build_description(cmd: CommandDef) -> str:
"""Build a CLI-facing description string including usage hint."""
if cmd.args_hint:
return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
return cmd.description
# Backwards-compatible flat dict ("/command" -> description) and the same
# grouped by category. Both exclude gateway_only commands.
COMMANDS: dict[str, str] = {}
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
SUBCOMMANDS: dict[str, list[str]] = {}
for _cmd in COMMAND_REGISTRY:
if _cmd.subcommands:
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
if _cmd.gateway_only:
continue
_entries = {f"/{_cmd.name}": _build_description(_cmd)}
for _alias in _cmd.aliases:
_entries[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
COMMANDS.update(_entries)
COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {}).update(_entries)
# /help renderer sub-groups for the ~46-command "Session" category. Category
# itself is load-bearing for gateway help, so commands are not re-tagged;
# unlisted Session commands fall under the base header. Bare names (no /).
HELP_SESSION_SUBGROUPS: dict[str, tuple[str, ...]] = {
"Context": (
"compress", "compact", "context", "ctx", "status",
),
"Background & Automation": (
"bg", "btw", "agents", "tasks", "queue", "q", "steer",
"goal", "subgoal", "heartbeat", "hb", "refine", "loop", "proactive",
"moa", "journey", "learning", "memory-graph",
),
}
# Fallback: derive subcommands from pipe patterns in args_hint ("[on|off|status]")
# for commands without an explicit ``subcommands`` field.
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
for _cmd in COMMAND_REGISTRY:
key = f"/{_cmd.name}"
if key in SUBCOMMANDS or not _cmd.args_hint:
continue
m = _PIPE_SUBS_RE.search(_cmd.args_hint)
if m:
SUBCOMMANDS[key] = m.group(0).split("|")
# ---------------------------------------------------------------------------
# Gateway helpers
# ---------------------------------------------------------------------------
# All names + aliases the gateway dispatches. Config-gated commands are
# included; their handler checks the gate at runtime.
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
name
for cmd in COMMAND_REGISTRY
if not cmd.cli_only or cmd.gateway_config_gate
for name in (cmd.name, *cmd.aliases)
)
def is_gateway_known_command(name: str | None) -> bool:
"""Return True if ``name`` is a built-in or plugin gateway slash command.
Plugin commands are looked up lazily so importing this module never forces
plugin discovery. Gateway code uses this to decide whether to emit
``command:<name>`` hooks — plugins get the same lifecycle events as built-ins.
"""
if not name:
return False
return name in GATEWAY_KNOWN_COMMANDS or any(
plugin_name == name for plugin_name, _d, _h in _iter_plugin_command_entries()
)
# Commands with explicit mid-run handling (busy_policy != "reject"). Kept
# under its historical name for introspection/tests; the real bypass set is
# every resolvable command (see should_bypass_active_session).
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
cmd.name for cmd in COMMAND_REGISTRY if cmd.busy_policy != "reject"
)
def is_interrupt_then_dispatch(command_name: str | None) -> bool:
"""True when *command_name* (or alias) has busy_policy "interrupt_then_dispatch".
Guard 1 (gateway/platforms/base.py) routes these through the cancel-handoff
path that serializes cancellation + runner response + pending drain.
"""
if not command_name:
return False
cmd = resolve_command(command_name)
return cmd is not None and cmd.busy_policy == "interrupt_then_dispatch"
def should_bypass_active_session(command_name: str | None) -> bool:
"""Return True for any resolvable slash command.
Every recognized slash command is dispatched mid-run — either by its
Level-2 handler in gateway/run.py or by the "busy — wait or /stop first"
catch-all — never queued: gateway.run's safety net discards command text
that reaches the pending queue, so a queued mid-run /model would interrupt
the agent AND vanish with a zero-char response.
ACTIVE_SESSION_BYPASS_COMMANDS is the subset with explicit handlers.
"""
return resolve_command(command_name) is not None if command_name else False
def _resolve_config_gates() -> set[str]:
"""Return canonical names of commands whose ``gateway_config_gate`` dotpath is truthy in config.yaml (empty set on any error)."""
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
if not gated:
return set()
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
except Exception:
return set()
result: set[str] = set()
for cmd in gated:
val: Any = cfg
for key in cmd.gateway_config_gate.split("."):
if isinstance(val, dict):
val = val.get(key)
else:
val = None
break
if is_truthy_value(val, default=False):
result.add(cmd.name)
return result
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
"""True if *cmd* appears in gateway surfaces: not ``cli_only``, or its config gate is truthy.
Pass *config_overrides* from ``_resolve_config_gates()`` to avoid
re-reading config per command.
"""
if not cmd.cli_only:
return True
if cmd.gateway_config_gate:
overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
return cmd.name in overrides
return False
def gateway_help_lines() -> list[str]:
"""Generate gateway help text lines from the registry."""
overrides = _resolve_config_gates()
lines: list[str] = []
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
continue
args = f" {cmd.args_hint}" if cmd.args_hint else ""
alias_parts: list[str] = []
for a in cmd.aliases:
# Skip internal aliases like reload_mcp (underscore variant)
if a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name:
continue
alias_parts.append(f"`/{a}`")
alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
return lines
def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
"""Return (name, description, args_hint) tuples for all plugin slash commands.
Registered via :func:`hermes_cli.plugins.PluginContext.register_command`;
surfaced like ``CommandDef`` entries (Telegram menu, Slack ``/hermes``
map, Discord picker). Lookup is lazy so importing this module never
forces plugin discovery (filesystem scans, env-dependent behavior).
"""
try:
from hermes_cli.plugins import get_plugin_commands
except Exception:
return []
try:
commands = get_plugin_commands() or {}
except Exception:
return []
entries: list[tuple[str, str, str]] = []
for name, meta in commands.items():
if not isinstance(name, str) or not isinstance(meta, dict):
continue
description = str(meta.get("description") or f"Run /{name}")
args_hint = str(meta.get("args_hint") or "").strip()
entries.append((name, description, args_hint))
return entries
# ---------------------------------------------------------------------------
# Lazy re-exports (PEP 562) of the extracted modules
# ---------------------------------------------------------------------------
# Platform derivations and the prompt_toolkit completer live in sibling
# modules; their names stay importable from here so every existing
# ``from hermes_cli.commands import X`` and ``patch("hermes_cli.commands.X")``
# keeps working. Resolving lazily keeps this registry module import-light for
# the gateway (no prompt_toolkit, no skill/plugin discovery at import time).
_LAZY_EXPORTS: dict[str, str] = {
name: "hermes_cli.commands_platforms"
for name in (
"_CMD_NAME_LIMIT", "_DEFAULT_TELEGRAM_MENU_MAX_COMMANDS", "_SLACK_RESERVED_COMMANDS",
"_SLACK_VIA_HERMES_ONLY", "_TELEGRAM_MENU_PRIORITY", "_clamp_command_names",
"_collect_gateway_skill_entries", "_iter_gateway_skills", "_prioritize_telegram_menu_candidates",
"_requires_argument", "_sanitize_slack_name", "_sanitize_telegram_name",
"_telegram_command_menu_config", "_truncate_desc", "discord_skill_commands_by_category",
"slack_app_manifest", "slack_native_slashes", "slack_subcommand_map", "telegram_bot_commands",
"telegram_menu_commands", "telegram_menu_max_commands",
)
}
_LAZY_EXPORTS.update({
name: "hermes_cli.commands_completion"
for name in ("SlashCommandCompleter", "SlashCommandAutoSuggest", "_file_size_label", "_short_desc")
})
def __getattr__(name: str):
module = _LAZY_EXPORTS.get(name)
if module is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(module), name)