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
This commit is contained in:
Teknium
2026-09-02 15:35:08 -07:00
parent 88b74d6ef0
commit 8eeb79aade
5 changed files with 1147 additions and 1321 deletions
+29 -1321
View File
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
"""prompt_toolkit completer + inline auto-suggest for slash commands.
Extracted from :mod:`hermes_cli.commands` (which re-exports
``SlashCommandCompleter`` / ``SlashCommandAutoSuggest``). The registry module
stays prompt_toolkit-free so the gateway can import it without the dependency.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from collections.abc import Callable, Iterable, Mapping
from itertools import chain
from typing import Any, Dict, Optional, Tuple
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
from prompt_toolkit.completion import Completer, Completion
from hermes_cli.commands import COMMANDS, SUBCOMMANDS
# (config-file signature, personalities) memo for /personality completion.
_personalities_memo: Optional[
Tuple[Tuple[Optional[str], Optional[int], Optional[int]], Dict[str, Any]]
] = None
def _personalities_from_cli_config() -> Dict[str, Any]:
"""``available_personalities(load_cli_config())`` memoised on config path+mtime+size.
load_cli_config() does a full YAML parse + deep merge and the completer
runs per keystroke; the result only changes when config.yaml changes on
disk (same pattern as load_env). Falls back to a fresh load when the file
cannot be stat'ed.
"""
global _personalities_memo
from cli import load_cli_config
from hermes_cli.personality import available_personalities
try:
from hermes_cli.config import get_config_path
cfg_path = get_config_path()
st = cfg_path.stat()
sig = (str(cfg_path), st.st_mtime_ns, st.st_size)
except Exception:
sig = (None, None, None)
if _personalities_memo is not None and _personalities_memo[0] == sig:
return _personalities_memo[1]
personalities = available_personalities(load_cli_config())
_personalities_memo = (sig, personalities)
return personalities
def _short_desc(info: Mapping[str, Any], default: str) -> str:
"""50-char description preview used in completion menus."""
description = str(info.get("description", default))
return description[:50] + ("..." if len(description) > 50 else "")
def _file_size_label(path: str) -> str:
"""Return a compact human-readable file size, or '' on error."""
try:
size = os.path.getsize(path)
except OSError:
return ""
if size < 1024:
return f"{size}B"
if size < 1024 * 1024:
return f"{size / 1024:.0f}K"
if size < 1024 * 1024 * 1024:
return f"{size / (1024 * 1024):.1f}M"
return f"{size / (1024 * 1024 * 1024):.1f}G"
def _prefix_completions(rows: Iterable[tuple[str, Any]], partial: str, *, skip_exact: bool = True):
"""Yield a Completion per ``(name, meta)`` whose name starts with *partial* (case-folded partial)."""
lowered = partial.lower()
for name, meta in rows:
if name.startswith(lowered) and not (skip_exact and name == lowered):
yield Completion(name, start_position=-len(partial), display=name, display_meta=meta)
def _split_args(sub_text: str) -> tuple[list[str], str]:
"""``(completed_words, partial)`` for multi-word argument text; a trailing space means a fresh word."""
parts = sub_text.split()
if sub_text.endswith(" "):
return parts, ""
return parts[:-1], (parts[-1] if parts else "")
# ---------------------------------------------------------------------------
# Dynamic argument completers: (sub_text, sub_lower) -> Completion iterator
# ---------------------------------------------------------------------------
def _skin_completions(sub_text: str, sub_lower: str):
"""/skin — available skins."""
try:
from hermes_cli.skin_engine import list_skins
rows = ((s["name"], s.get("description", "") or s.get("source", "")) for s in list_skins())
yield from _prefix_completions(rows, sub_text)
except Exception:
pass
def _personality_completions(sub_text: str, sub_lower: str):
"""/personality — ``none`` plus configured personalities."""
try:
from hermes_cli.personality import describe_personality
personalities = _personalities_from_cli_config()
rows = chain([("none", "clear personality overlay")],
((name, describe_personality(prompt)) for name, prompt in personalities.items()))
yield from _prefix_completions(rows, sub_text)
except Exception:
pass
def _tools_completions(sub_text: str, sub_lower: str):
"""/tools — subcommand, then toolset / MCP-server names for enable|disable.
Toolsets are offered only when the subcommand would change their state
(enable → currently off, disable → currently on); MCP server prefixes are
always offered.
"""
completed, partial = _split_args(sub_text)
if not completed:
yield from _prefix_completions(((s, None) for s in ("list", "disable", "enable")), partial)
return
subcommand = completed[0].lower()
if subcommand not in ("enable", "disable"):
return
already = set(completed[1:])
try:
from hermes_cli.config import load_config_readonly
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_platform_tools, _get_plugin_toolset_keys
# Readonly loader: this runs per keystroke and never mutates config,
# so skip the defensive deepcopy of load_config().
config = load_config_readonly()
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
mcp_servers = config.get("mcp_servers") or {}
want_enabled = subcommand != "enable"
rows = [(k, label) for k, label, _d in CONFIGURABLE_TOOLSETS if (k in enabled) == want_enabled]
rows += [(k, "plugin toolset") for k in sorted(_get_plugin_toolset_keys()) if (k in enabled) == want_enabled]
if isinstance(mcp_servers, dict):
rows += [(f"{srv}:", f"MCP server '{srv}'") for srv in sorted(mcp_servers)]
yield from _prefix_completions(((k, m) for k, m in rows if k not in already), partial, skip_exact=False)
except Exception:
return
def _handoff_completions(sub_text: str, sub_lower: str):
"""/handoff — connected (enabled + configured) gateway platforms, first arg only.
A recorded home channel is NOT required to list a platform — it's often
learned at runtime — so the meta hints whether one is set yet.
"""
completed, partial = _split_args(sub_text)
if completed:
return
try:
from gateway.config import load_gateway_config
gw = load_gateway_config()
platforms = gw.get_connected_platforms()
except Exception:
return
for platform in platforms:
name = platform.value
if not name.startswith(partial.lower()):
continue
try:
home = gw.get_home_channel(platform)
except Exception:
home = None
meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here"
yield Completion(name, start_position=-len(partial), display=name, display_meta=meta)
# base command -> (handler(sub_text, sub_lower), single_word_only).
# Single-word handlers only run while the first argument is being typed;
# /tools and /handoff parse multi-word input themselves, bypassing the
# static SUBCOMMANDS branch.
_DYNAMIC_COMPLETIONS: dict[str, tuple[Callable[..., Any], bool]] = {
"/skin": (_skin_completions, True),
"/personality": (_personality_completions, True),
"/tools": (_tools_completions, False),
"/handoff": (_handoff_completions, False),
}
# ---------------------------------------------------------------------------
# Path / @-context completion
# ---------------------------------------------------------------------------
def _extract_path_word(text: str) -> str | None:
"""Path-like word under the cursor (``./``, ``../``, ``~/``, ``/`` or contains ``/``), else None.
Tokens with a ``://`` scheme are excluded — treating a pasted URL as a
path fires os.listdir per keystroke for no useful result.
"""
word = text.rpartition(" ")[2]
if not word or "://" in word:
return None
return word if "/" in word else None
def _dir_completions(expanded: str, word: str, limit: int, text_for: Callable[[str], str], want_dir: bool | None = None):
"""Yield directory-listing completions for the path *expanded*.
Entries of the parent dir are matched case-insensitively on the typed
basename (all entries after a trailing ``/``), sorted by name, and
limited to *limit*. ``text_for(full_path)`` builds the completion text
(without the trailing ``/``); *want_dir* restricts to dirs / files.
"""
if expanded.endswith("/"):
search_dir, prefix = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
prefix = os.path.basename(expanded)
try:
entries = os.listdir(search_dir)
except OSError:
return
prefix_lower = prefix.lower()
count = 0
for entry in sorted(entries):
if prefix and not entry.lower().startswith(prefix_lower):
continue
full_path = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full_path)
if want_dir is not None and want_dir != is_dir:
continue
if count >= limit:
break
suffix = "/" if is_dir else ""
yield Completion(
text_for(full_path) + suffix,
start_position=-len(word),
display=entry + suffix,
display_meta="dir" if is_dir else _file_size_label(full_path),
)
count += 1
def _path_completions(word: str, limit: int = 30):
"""Yield Completion objects for file paths matching *word*, keeping the user's path style (~, absolute, relative)."""
if word.startswith("~"):
text_for = lambda fp: "~/" + os.path.relpath(fp, os.path.expanduser("~")) # noqa: E731
elif os.path.isabs(word):
text_for = lambda fp: fp # noqa: E731
else:
text_for = os.path.relpath
yield from _dir_completions(os.path.expanduser(word), word, limit, text_for)
_STATIC_CONTEXT_REFS = (
("@diff", "Git working tree diff"),
("@staged", "Git staged diff"),
("@file:", "Attach a file"),
("@folder:", "Attach a folder"),
("@git:", "Git log with diffs (e.g. @git:5)"),
("@url:", "Fetch web content"),
)
def _score_path(filepath: str, query: str) -> int:
"""Score a file path against a fuzzy query. Higher = better match; 0 = no match."""
if not query:
return 1 # show everything when query is empty
lower_file = os.path.basename(filepath).lower()
lower_q = query.lower()
if lower_file == lower_q:
return 100
if lower_file.startswith(lower_q):
return 80
if lower_q in lower_file:
return 60
if lower_q in filepath.lower():
return 40
# Abbreviation match: query chars appear in order in the filename ("fo" ~
# "file_operations"); bonus when >= half land on word boundaries (_-./).
qi = boundary_hits = 0
prev = "_" # treat start as boundary
for c in lower_file:
if qi < len(lower_q) and c == lower_q[qi]:
boundary_hits += prev in "_-./"
qi += 1
prev = c
if qi < len(lower_q):
return 0
return 35 if boundary_hits >= len(lower_q) * 0.5 else 25
class SlashCommandCompleter(Completer):
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
# Commands that open pickers when run bare. No trailing space for these:
# the TUI applies the completion on Enter, and "/model " blocks the picker.
_PICKER_COMMANDS = frozenset({"model", "skin", "personality"})
# Module-level helpers exposed as staticmethods for existing callers/tests.
_extract_path_word = staticmethod(_extract_path_word)
_dir_completions = staticmethod(_dir_completions)
_path_completions = staticmethod(_path_completions)
_score_path = staticmethod(_score_path)
_skin_completions = staticmethod(_skin_completions)
_personality_completions = staticmethod(_personality_completions)
_tools_completions = staticmethod(_tools_completions)
_handoff_completions = staticmethod(_handoff_completions)
_DYNAMIC_COMPLETIONS = _DYNAMIC_COMPLETIONS
def __init__(
self,
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
command_filter: Callable[[str], bool] | None = None,
skill_bundles_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
) -> None:
self._skill_commands_provider = skill_commands_provider
self._command_filter = command_filter
self._skill_bundles_provider = skill_bundles_provider
# Cached project file list for fuzzy @ completions
self._file_cache: list[str] = []
self._file_cache_time: float = 0.0
self._file_cache_cwd: str = ""
def _command_allowed(self, slash_command: str) -> bool:
if self._command_filter is None:
return True
try:
return bool(self._command_filter(slash_command))
except Exception:
return True
@staticmethod
def _call_provider(provider) -> Mapping[str, dict[str, Any]]:
if provider is None:
return {}
try:
return provider() or {}
except Exception:
return {}
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
return self._call_provider(self._skill_commands_provider)
def _iter_skill_bundles(self) -> Mapping[str, dict[str, Any]]:
return self._call_provider(self._skill_bundles_provider)
# -- stacked slash-skill completion helpers ---------------------------
@staticmethod
def _normalize_skill_token(token: str) -> str:
"""Canonical hyphenated /slug form; mirrors resolve_skill_command_key() (underscores == hyphens)."""
return "/" + token.lstrip("/").replace("_", "-").lower()
def _is_skill_command(self, token: str) -> bool:
return self._normalize_skill_token(token) in self._iter_skill_commands()
def _stacked_skill_completions(self, text: str):
"""Offer skill-command completions for stacked invocations (``/skill-a /skill-b do XYZ``).
Keep suggesting while every completed token is a distinct skill
command, the cap is not reached, and the current word starts with
``/``; once the chain breaks, offer nothing — instruction text must
never be polluted with skill suggestions.
"""
try:
from agent.skill_commands import _MAX_STACKED_SKILLS as _cap
except Exception:
_cap = 5
completed, current_word = _split_args(text)
skill_cmds = self._iter_skill_commands()
seen: set[str] = set()
for token in completed:
key = self._normalize_skill_token(token)
if key not in skill_cmds or key in seen:
return
seen.add(key)
# A bare space after the chain means they may be starting the instruction.
if len(seen) >= _cap or not current_word.startswith("/"):
return
word_key = self._normalize_skill_token(current_word)
for cmd, info in skill_cmds.items():
if cmd in seen or not cmd.startswith(word_key):
continue
# Exact match: trailing space keeps the dropdown visible so the
# next stacked token can be typed immediately (see _completion_text).
yield Completion(
f"{cmd} " if cmd == word_key else cmd,
start_position=-len(current_word),
display=cmd,
display_meta=f"⚡ {_short_desc(info, 'Skill command')}",
)
@staticmethod
def _completion_text(cmd_name: str, word: str) -> str:
"""Replacement text: on an exact match a no-op replacement makes prompt_toolkit
suppress the menu, so a trailing space is appended — except for _PICKER_COMMANDS."""
if cmd_name != word or cmd_name in SlashCommandCompleter._PICKER_COMMANDS:
return cmd_name
return f"{cmd_name} "
@staticmethod
def _extract_context_word(text: str) -> str | None:
"""Extract a bare ``@`` token for context reference completions."""
word = text.rpartition(" ")[2]
return word if word.startswith("@") else None
def _context_completions(self, word: str, limit: int = 30):
"""Claude Code-style @ completions: static refs, ``@file:``/``@folder:`` paths, else fuzzy project files."""
lowered = word.lower()
for candidate, meta in _STATIC_CONTEXT_REFS:
if candidate.startswith(lowered) and candidate != lowered:
yield Completion(candidate, start_position=-len(word), display=candidate, display_meta=meta)
# Accepting the bare `@file` / `@folder` (no colon yet) lets the picker
# surface entries without first accepting the static hint.
for prefix in ("@file:", "@folder:"):
bare = prefix[:-1]
if word == bare or word.startswith(prefix):
expanded = os.path.expanduser("" if word == bare else word[len(prefix):])
if not expanded or expanded == ".":
expanded = "./"
# `@folder:` surfaces only directories, `@file:` only regular
# files — otherwise `@folder:` lists every dotfile in cwd.
yield from _dir_completions(
expanded, word, limit,
lambda fp: f"{prefix}{os.path.relpath(fp)}",
want_dir=(prefix == "@folder:"),
)
return
yield from self._fuzzy_file_completions(word, word[1:], limit)
def _get_project_files(self) -> list[str]:
"""Return cached list of project files (refreshed every 5s); rg (gitignore-aware) then fd."""
cwd = os.getcwd()
now = time.monotonic()
if self._file_cache and self._file_cache_cwd == cwd and now - self._file_cache_time < 5.0:
return self._file_cache
files: list[str] = []
for cmd in (
["rg", "--files", "--sortr=modified", cwd],
["rg", "--files", cwd],
["fd", "--type", "f", "--base-directory", cwd],
):
if not shutil.which(cmd[0]):
continue
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=2,
cwd=cwd, encoding="utf-8", errors="replace",
)
except (subprocess.TimeoutExpired, OSError):
continue
if proc.returncode != 0 or not proc.stdout.strip():
continue
for p in proc.stdout.strip().split("\n")[:5000]:
try:
files.append(os.path.relpath(p, cwd) if os.path.isabs(p) else p)
except ValueError:
# Windows: relpath raises for paths on a different mount than
# cwd (\\.\nul, other drive letter). One bad entry must not
# crash the @ autocomplete event loop.
continue
break
self._file_cache, self._file_cache_time, self._file_cache_cwd = files, now, cwd
return files
def _fuzzy_file_completions(self, word: str, query: str, limit: int = 20):
"""Yield fuzzy file completions for bare @query (no query = recently modified files)."""
files = self._get_project_files()
if not query:
ranked = files[:limit]
else:
scored = [(s, fp) for fp in files if (s := _score_path(fp, query)) > 0]
scored.sort(key=lambda x: (-x[0], x[1]))
ranked = [fp for _, fp in scored[:limit]]
for fp in ranked:
is_dir = fp.endswith("/")
meta = "dir" if is_dir else _file_size_label(os.path.join(os.getcwd(), fp))
if query:
meta = f"{fp} {meta}" if meta else fp
yield Completion(
f"@{'folder' if is_dir else 'file'}:{fp}",
start_position=-len(word),
display=os.path.basename(fp),
display_meta=meta,
)
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
ctx_word = self._extract_context_word(text)
if ctx_word is not None:
yield from self._context_completions(ctx_word)
return
path_word = _extract_path_word(text)
if path_word is not None:
yield from _path_completions(path_word)
return
parts = text.split(maxsplit=1)
base_cmd = parts[0].lower()
if len(parts) > 1 or text.endswith(" "):
# Completing arguments: base command already typed.
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
# Stacked slash-skill chain (`/skill-a /skill-b …`), see
# split_stacked_skill_commands in agent/skill_commands.py.
if self._is_skill_command(base_cmd):
yield from self._stacked_skill_completions(text)
return
dynamic = _DYNAMIC_COMPLETIONS.get(base_cmd)
if dynamic is not None:
handler, single_word = dynamic
if not single_word or " " not in sub_text:
yield from handler(sub_text, sub_lower)
return
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
yield from _prefix_completions(((s, None) for s in SUBCOMMANDS[base_cmd]), sub_text)
return
word = text[1:]
def _cmd_completion(cmd_name: str, meta: str):
return Completion(
self._completion_text(cmd_name, word),
start_position=-len(word),
display=f"/{cmd_name}",
display_meta=meta,
)
for cmd, desc in COMMANDS.items():
if self._command_allowed(cmd) and cmd[1:].startswith(word):
yield _cmd_completion(cmd[1:], desc)
for cmd, info in self._iter_skill_bundles().items():
if cmd[1:].startswith(word):
skill_count = len(info.get("skills", []))
yield _cmd_completion(cmd[1:], f"▣ {_short_desc(info, 'Skill bundle')} ({skill_count} skills)")
for cmd, info in self._iter_skill_commands().items():
if cmd[1:].startswith(word):
yield _cmd_completion(cmd[1:], f"⚡ {_short_desc(info, 'Skill command')}")
try:
from hermes_cli.plugins import get_plugin_commands
for cmd_name, cmd_info in get_plugin_commands().items():
if cmd_name.startswith(word):
yield _cmd_completion(cmd_name, f"🔌 {_short_desc(cmd_info, 'Plugin command')}")
except Exception:
pass
class SlashCommandAutoSuggest(AutoSuggest):
"""Inline ghost-text for slash commands and their subcommands; history fallback for other input."""
def __init__(
self,
history_suggest: AutoSuggest | None = None,
completer: SlashCommandCompleter | None = None,
) -> None:
self._history = history_suggest
self._completer = completer # Reuse its model cache
def _allowed(self, cmd: str) -> bool:
return self._completer is None or self._completer._command_allowed(cmd)
def get_suggestion(self, buffer, document):
text = document.text_before_cursor
if not text.startswith("/"):
return self._history_suggestion(buffer, document)
parts = text.split(maxsplit=1)
base_cmd = parts[0].lower()
if len(parts) == 1 and not text.endswith(" "):
# Still typing the command name: /upd → "ate". Prefer the SHORTEST
# match so /he ghosts "lp" (/help), not "artbeat" (/heartbeat).
word = text[1:].lower()
for cmd in sorted(COMMANDS, key=len):
cmd_name = cmd[1:]
if self._allowed(cmd) and cmd_name.startswith(word) and cmd_name != word:
return Suggestion(cmd_name[len(word):])
return None
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
# Stacked skill chain: ghost-suggest the rest of the next skill name;
# otherwise fall through to the history fallback for instruction text.
if self._completer is not None and self._completer._is_skill_command(base_cmd):
for completion in self._completer._stacked_skill_completions(text):
remainder = completion.text[-completion.start_position:] \
if completion.start_position else completion.text
if remainder.strip():
return Suggestion(remainder)
if not self._allowed(base_cmd):
return None
if " " not in sub_text:
for sub in SUBCOMMANDS.get(base_cmd, ()):
if sub.startswith(sub_lower) and sub != sub_lower:
return Suggestion(sub[len(sub_text):])
return self._history_suggestion(buffer, document)
def _history_suggestion(self, buffer, document):
return self._history.get_suggestion(buffer, document) if self._history else None
+495
View File
@@ -0,0 +1,495 @@
"""Gateway platform command derivations (Telegram / Discord / Slack) from ``COMMAND_REGISTRY``.
Extracted from :mod:`hermes_cli.commands`, which re-exports every name here so
``from hermes_cli.commands import telegram_menu_commands`` keeps working.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from hermes_cli.commands import (
COMMAND_REGISTRY,
_is_gateway_available,
_iter_plugin_command_entries,
_resolve_config_gates,
)
# Logger name parity with the origin module (tests capture "hermes_cli.commands").
logger = logging.getLogger("hermes_cli.commands")
_CMD_NAME_LIMIT = 32
"""Max command name length shared by Telegram and Discord."""
_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]")
_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}")
def _requires_argument(args_hint: str) -> bool:
"""True when selecting a command without text would be incomplete."""
return args_hint.strip().startswith("<")
def _sanitize_telegram_name(raw: str) -> str:
"""Telegram allows only ``[a-z0-9_]``: lowercase → hyphens to underscores → strip the rest → collapse/strip ``_``."""
name = _TG_INVALID_CHARS.sub("", raw.lower().replace("-", "_"))
return _TG_MULTI_UNDERSCORE.sub("_", name).strip("_")
def _truncate_desc(desc: str, limit: int) -> str:
"""Clamp a menu description to *limit* chars with a ``...`` tail."""
return desc if len(desc) <= limit else desc[:limit - 3] + "..."
def _clamp_command_names(entries: Sequence[tuple[str, ...]], reserved: set[str]) -> list[tuple[str, ...]]:
"""Enforce the 32-char Telegram/Discord name limit with collision avoidance.
Over-long names are truncated; if that collides with *reserved* or an
earlier entry, a 31-char prefix + digit ``0``-``9`` is tried, and the entry
is silently dropped when all ten are taken. Duplicate names are dropped.
Elements beyond ``(name, desc)`` pass through unchanged.
"""
used: set[str] = set(reserved)
result: list = []
for name, desc, *extra in entries:
if len(name) > _CMD_NAME_LIMIT:
candidate = name[:_CMD_NAME_LIMIT]
if candidate in used:
prefix = name[:_CMD_NAME_LIMIT - 1]
for digit in range(10):
candidate = f"{prefix}{digit}"
if candidate not in used:
break
else:
continue
name = candidate
if name in used:
continue
used.add(name)
result.append((name, desc, *extra))
return result
# ---------------------------------------------------------------------------
# Telegram
# ---------------------------------------------------------------------------
def telegram_bot_commands(*, include_plugins: bool = True) -> list[tuple[str, str]]:
"""Return (command_name, description) pairs for Telegram setMyCommands.
Names are Telegram-sanitized; aliases are skipped (one menu entry per
canonical command). Built-ins that require arguments are **included** —
their handlers show usage text when selected bare. Plugin commands
requiring arguments are **excluded** because plugins may lack a no-arg
fallback; callers needing source metadata pass ``include_plugins=False``
and use :func:`_collect_gateway_skill_entries`.
"""
overrides = _resolve_config_gates()
pairs = [(cmd.name, cmd.description) for cmd in COMMAND_REGISTRY if _is_gateway_available(cmd, overrides)]
if include_plugins:
pairs += [(n, d) for n, d, hint in _iter_plugin_command_entries() if not _requires_argument(hint)]
return [(tg, desc) for name, desc in pairs if (tg := _sanitize_telegram_name(name))]
# Telegram allows 100 BotCommands; the 60-slot default keeps every built-in
# plus common skill commands visible while staying under the ~4KB payload
# limit. Tunable via platforms.telegram.extra.command_menu.max_commands.
_DEFAULT_TELEGRAM_MENU_MAX_COMMANDS = 60
_TELEGRAM_BOT_API_MAX_COMMANDS = 100
# priority_mode -> rank tables consulted in order ("configured" = user list,
# "default" = _TELEGRAM_MENU_PRIORITY); unranked candidates keep stable order after.
_TELEGRAM_PRIORITY_TIERS: dict[str, tuple[str, ...]] = {
"prepend": ("configured", "default"),
"append": ("default", "configured"),
"replace": ("configured",),
}
# Built-ins that must survive Telegram's small visible menu cap; everything
# else stays dispatchable when typed manually. Order = rank.
_TELEGRAM_MENU_PRIORITY = (
# Most-typed everyday commands first.
"help", "new", "stop", "status", "egress", "resume", "sessions", "model",
# Maintenance / diagnostics.
"debug", "restart", "update", "verbose", "commands",
# Mid-turn session control.
"approve", "deny", "queue", "steer", "bg", "btw",
# Lower-priority but still useful operational built-ins.
"reasoning", "usage", "platforms", "platform", "profile", "whoami",
)
def _telegram_command_menu_config() -> dict[str, Any]:
"""Normalized ``platforms.telegram.extra.command_menu`` config with safe defaults."""
try:
from hermes_cli.config import read_raw_config
node: Any = read_raw_config() or {}
except Exception:
node = {}
for key in ("platforms", "telegram", "extra", "command_menu"):
node = node.get(key) if isinstance(node, Mapping) else None
menu_cfg: Mapping[str, Any] = node if isinstance(node, Mapping) else {}
try:
max_commands = int(menu_cfg.get("max_commands", _DEFAULT_TELEGRAM_MENU_MAX_COMMANDS))
except (TypeError, ValueError):
max_commands = _DEFAULT_TELEGRAM_MENU_MAX_COMMANDS
priority_mode = str(menu_cfg.get("priority_mode") or "prepend").strip().lower()
raw_priority = menu_cfg.get("priority")
if isinstance(raw_priority, list):
priority = [str(item) for item in raw_priority if str(item).strip()]
else:
priority = [raw_priority] if isinstance(raw_priority, str) and raw_priority.strip() else []
return {
"max_commands": max(1, min(_TELEGRAM_BOT_API_MAX_COMMANDS, max_commands)),
"priority_mode": priority_mode if priority_mode in _TELEGRAM_PRIORITY_TIERS else "prepend",
"priority": priority,
}
def telegram_menu_max_commands() -> int:
"""Return configured Telegram BotCommand menu cap with safe bounds."""
return int(_telegram_command_menu_config()["max_commands"])
def _sanitized_rank(raw_names: Sequence[str]) -> dict[str, int]:
"""name -> rank for the deduped, Telegram-sanitized *raw_names* (first occurrence wins)."""
rank: dict[str, int] = {}
for raw in raw_names:
name = _sanitize_telegram_name(str(raw))
if name and name not in rank:
rank[name] = len(rank)
return rank
def _prioritize_telegram_menu_candidates(
candidates: list[tuple[str, str, str, str]],
) -> list[tuple[str, str, str, str]]:
"""Order ``(final_name, description, source, raw_name)`` candidates; default priority applies to core only.
``raw_name`` is the pre-clamp name so an explicitly configured long command
stays addressable after Telegram name clamping. "replace" mode ignores the
built-in defaults entirely.
"""
# Lazy origin import: tests patch ``hermes_cli.commands._telegram_command_menu_config``.
from hermes_cli.commands import _telegram_command_menu_config as menu_config
menu_cfg = menu_config()
configured_rank = _sanitized_rank(menu_cfg["priority"])
default_rank = _sanitized_rank(_TELEGRAM_MENU_PRIORITY)
tiers = _TELEGRAM_PRIORITY_TIERS[menu_cfg["priority_mode"]]
def _rank(stable_index: int, candidate: tuple[str, str, str, str]) -> tuple[int, int, int]:
final_name, _desc, source, raw_name = candidate
indexes = {
"configured": configured_rank.get(raw_name, configured_rank.get(final_name)),
"default": default_rank.get(final_name) if source == "core" else None,
}
for tier, table in enumerate(tiers):
if indexes[table] is not None:
return (tier, indexes[table], stable_index)
return (len(tiers), 0, stable_index)
return [c for _i, c in sorted(enumerate(candidates), key=lambda item: _rank(*item))]
# ---------------------------------------------------------------------------
# Shared skill/plugin collection for gateway platforms
# ---------------------------------------------------------------------------
def _iter_gateway_skills(platform: str):
"""Yield ``(cmd_key, info, rel_parts)`` for skills eligible as gateway slash commands.
Scan roots are the local ``SKILLS_DIR`` plus every configured
``skills.external_dirs`` / trusted project skills dir — a skill anywhere
else, or under the hub dir (``SKILLS_DIR/.hub``), is skipped, as are skills
disabled for *platform*. Paths are resolved on both sides so symlinked roots
(macOS ``/var`` → ``/private/var``) still match, and matching is per path
component so ``/my-skills`` never admits ``/my-skills-extra``. ``rel_parts``
is the skill dir relative to its root (``("creative", "ascii-art")``) for
category derivation. Iterates ``sorted(skill_cmds)`` so first-wins
collision handling is alphabetical.
"""
from pathlib import Path
from agent.skill_commands import get_skill_commands
from agent.skill_utils import get_disabled_skill_names, get_external_skills_dirs, get_project_skills_dirs
from tools.skills_tool import SKILLS_DIR
try:
disabled = get_disabled_skill_names(platform=platform)
except Exception:
disabled = set()
hub_dir = (SKILLS_DIR / ".hub").resolve()
roots = [SKILLS_DIR.resolve()]
for getter in (get_external_skills_dirs, get_project_skills_dirs):
try:
for d in getter():
try:
roots.append(Path(d).resolve())
except Exception:
continue
except Exception:
pass
skill_cmds = get_skill_commands()
for cmd_key in sorted(skill_cmds):
info = skill_cmds[cmd_key]
skill_path = info.get("skill_md_path", "")
if not skill_path:
continue
sp = Path(skill_path).resolve()
if sp.is_relative_to(hub_dir):
continue
root = next((r for r in roots if sp.is_relative_to(r)), None)
if root is None or info.get("name", "") in disabled:
continue
yield cmd_key, info, sp.parent.relative_to(root).parts
def _collect_gateway_skill_entries(
platform: str,
max_slots: int | None,
reserved_names: set[str],
desc_limit: int = 100,
sanitize_name: "Callable[[str], str] | None" = None,
) -> tuple[list[tuple[str, str, str, str]], int]:
"""Collect plugin + skill entries for a gateway platform.
Plugin slash commands come first and are never trimmed; skill commands
(alphabetical, see :func:`_iter_gateway_skills`) fill the remaining
*max_slots* — ``None`` returns every candidate for a caller applying its
own global cap. *reserved_names* (built-in names) is mutated in place as
names are claimed. *sanitize_name* runs before clamping and may return ""
to skip an entry. Returns ``(entries, hidden_count)`` with entries of
``(name, description, cmd_key, raw_name)`` — ``cmd_key`` is the original
skill key ("" for plugins); ``raw_name`` the sanitized pre-clamp name used
for configured-priority matching.
"""
sanitize = sanitize_name or (lambda n: n)
# Tier 1: plugin slash commands (never trimmed). No cmd_key — "" placeholder.
plugin_entries: list[tuple[str, str, str, str]] = []
try:
from hermes_cli.plugins import get_plugin_commands
plugin_cmds = get_plugin_commands()
for cmd_name in sorted(plugin_cmds):
if platform == "telegram" and _requires_argument(str(plugin_cmds[cmd_name].get("args_hint") or "")):
continue
if name := sanitize(cmd_name):
desc = _truncate_desc(plugin_cmds[cmd_name].get("description", "Plugin command"), desc_limit)
plugin_entries.append((name, desc, "", name))
except Exception:
pass
plugin_entries = _clamp_command_names(plugin_entries, reserved_names)
reserved_names.update(n for n, *_rest in plugin_entries)
# Tier 2: skill commands (the only tier trimmed at the cap). cmd_key and
# raw_name survive any clamp-induced rename.
skill_entries: list[tuple[str, str, str, str]] = []
try:
for cmd_key, info, _rel in _iter_gateway_skills(platform):
if name := sanitize(cmd_key.lstrip("/")):
skill_entries.append((name, _truncate_desc(info.get("description", ""), desc_limit), cmd_key, name))
except Exception:
pass
skill_entries = _clamp_command_names(skill_entries, reserved_names)
if max_slots is None:
return plugin_entries + skill_entries, 0
remaining = max(0, max_slots - len(plugin_entries))
hidden_count = max(0, len(skill_entries) - remaining)
return (plugin_entries + skill_entries[:remaining])[:max_slots], hidden_count
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
"""Return ``(menu_commands, hidden_count)`` for Telegram, capped to the Bot API limit.
Tier order: core CommandDefs, then plugin slash commands, then skill
commands (alphabetical; hub skills and telegram-disabled skills excluded).
Tiers keep their relative order unless named in
``platforms.telegram.extra.command_menu.priority`` — explicit priority is
applied to the combined list *before* the cap, so a prioritized dynamic
command can displace an unprioritized core command.
"""
# Lazy origin import: tests patch ``hermes_cli.commands.telegram_bot_commands``.
from hermes_cli.commands import telegram_bot_commands as bot_commands
core_commands = list(bot_commands(include_plugins=False))
entries, hidden_count = _collect_gateway_skill_entries(
platform="telegram",
max_slots=None,
reserved_names={n for n, _ in core_commands},
desc_limit=40,
sanitize_name=_sanitize_telegram_name,
)
candidates = [(name, desc, "core", name) for name, desc in core_commands]
candidates += [(name, desc, "skill" if cmd_key else "plugin", raw) for name, desc, cmd_key, raw in entries]
candidates = _prioritize_telegram_menu_candidates(candidates)
overflow_count = max(0, len(candidates) - max_commands)
menu = [(name, desc) for name, desc, _source, _raw_name in candidates[:max_commands]]
return menu, hidden_count + overflow_count
# ---------------------------------------------------------------------------
# Discord
# ---------------------------------------------------------------------------
def discord_skill_commands_by_category(
reserved_names: set[str],
) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]:
"""Return ``(categories, uncategorized, hidden_count)`` for Discord ``/skill`` autocomplete.
Skills nested >= 2 levels under a scan root (``creative/ascii-art/SKILL.md``)
are grouped under ``categories[top_level]``; root-level skills are
*uncategorized*. Entries are ``(name, description, cmd_key)`` with names
clamped to 32 chars and descriptions to 100. Eligibility follows
:func:`_iter_gateway_skills`. No per-group cap is applied — the caller
flattens everything into one autocomplete callback, which scales to
thousands of entries; ``hidden_count`` only reports 32-char clamp
collisions against reserved names or earlier skills.
"""
categories: dict[str, list[tuple[str, str, str]]] = {}
uncategorized: list[tuple[str, str, str]] = []
# clamped name → origin. Reserved (gateway-builtin) names carry a sentinel
# so the warning can distinguish "collided with a reserved command" from
# "two skills collided on the 32-char clamp" — the rename-worthy case.
names_used: dict[str, str] = dict.fromkeys(reserved_names, "<reserved>")
hidden = 0
try:
for cmd_key, info, rel_parts in _iter_gateway_skills("discord"):
# On collision the first (alphabetical) skill wins and the loser is
# dropped from the picker; warn loudly, since a silent ``hidden``
# count gave skill authors no way to discover the drop.
discord_name = cmd_key.lstrip("/")[:32]
prior = names_used.get(discord_name)
if prior == "<reserved>":
logger.warning(
"Discord /skill: %r (from %r) collides on its 32-char "
"clamp with a reserved gateway command name %r — the "
"skill will not appear in the /skill autocomplete. "
"Rename the skill's frontmatter ``name:`` to differ "
"in its first 32 chars.",
discord_name, cmd_key, discord_name,
)
elif prior is not None:
logger.warning(
"Discord /skill: %r and %r both clamp to %r on "
"Discord's 32-char command-name limit — only %r "
"will appear in the /skill autocomplete. Rename "
"one skill's frontmatter ``name:`` to differ in "
"its first 32 chars.",
prior, cmd_key, discord_name, prior,
)
if prior is not None:
hidden += 1
continue
names_used[discord_name] = cmd_key
entry = (discord_name, _truncate_desc(info.get("description", ""), 100), cmd_key)
# creative/ascii-art/SKILL.md → category "creative"; root-level skills are uncategorized.
(categories.setdefault(rel_parts[0], []) if len(rel_parts) >= 2 else uncategorized).append(entry)
except Exception:
pass
return categories, uncategorized, hidden
# ---------------------------------------------------------------------------
# Slack native slash commands
# ---------------------------------------------------------------------------
# Slack slash names: lowercase a-z, 0-9, hyphens, underscores, max 32 chars;
# an app manifest accepts up to 50 slash commands.
_SLACK_MAX_SLASH_COMMANDS = 50
_SLACK_NAME_LIMIT = 32
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
_SLACK_RESERVED_COMMANDS = frozenset({
# Built-in Slack slash commands that cannot be registered by apps.
# https://slack.com/help/articles/201259356-Use-built-in-slash-commands
"me", "status", "away", "dnd", "shrug", "remind", "msg", "feed",
"who", "collapse", "expand", "leave", "join", "open", "search",
"topic", "mute", "pro", "shortcuts",
})
# Canonical commands intentionally NOT given a native Slack slash slot. Slack
# caps apps at 50 slash commands and the registry is at that ceiling; rather
# than let the clamp silently drop whichever command sorts last (breaking the
# Telegram-parity test), low-frequency commands are routed through
# ``/hermes <command>`` on Slack only. They stay native on every other surface.
# Rule: when a new canonical command tips the registry past the cap, demote a
# rarer one-off lookup here (version, whoami, platform, diff, update, ...)
# rather than a recurring interactive surface (context, loop, save, approvals).
# Keep TIGHT and intentional — the parity test reads this set. (Aliases are
# never pinned ahead of canonicals: /bg and /btw became canonical commands
# instead, so they win first-pass slots on their own.)
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights"})
def _sanitize_slack_name(raw: str) -> str:
"""Lowercase, strip chars outside ``[a-z0-9_-]`` and edge ``-_``, clamp to 32."""
return _SLACK_INVALID_CHARS.sub("", raw.lower()).strip("-_")[:_SLACK_NAME_LIMIT]
def slack_native_slashes() -> list[tuple[str, str, str]]:
"""Return (slash_name, description, usage_hint) triples for Slack.
Every gateway-available command (canonical names first, then aliases,
then plugin commands) becomes a standalone Slack slash, clamped to the
50-command cap with duplicate avoidance. Names colliding with a Slack
built-in (``/status``, ``/me``, ...) or listed in _SLACK_VIA_HERMES_ONLY
are skipped. ``/hermes`` is always the first entry so the
``/hermes <command>`` form keeps working for anything dropped.
"""
overrides = _resolve_config_gates()
available = [cmd for cmd in COMMAND_REGISTRY if _is_gateway_available(cmd, overrides)]
# Canonical names first so they win slots at the cap; aliases second; plugins third.
wanted = [(cmd.name, cmd.description, cmd.args_hint or "") for cmd in available]
wanted += [(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
for cmd in available for alias in cmd.aliases]
wanted += [(name, desc, hint or "") for name, desc, hint in _iter_plugin_command_entries()]
entries: list[tuple[str, str, str]] = [("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]")]
seen = {"hermes"}
for name, desc, hint in wanted:
slack_name = _sanitize_slack_name(name)
if (
not slack_name
or slack_name in seen
or slack_name in _SLACK_RESERVED_COMMANDS
or slack_name in _SLACK_VIA_HERMES_ONLY
or len(entries) >= _SLACK_MAX_SLASH_COMMANDS
):
continue
# Slack description cap is 2000 chars; keep it short.
entries.append((slack_name, desc[:140], hint[:100]))
seen.add(slack_name)
return entries
def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]:
"""Return the ``features.slash_commands`` manifest portion for all gateway slashes.
``request_url`` is schema-required but ignored in Socket Mode (a
placeholder is fine). Only this portion is returned so we stay decoupled
from the rest of the manifest users configure once in the Slack UI.
"""
slashes = []
for name, desc, usage in slack_native_slashes():
entry = {"command": f"/{name}", "description": desc or f"Run /{name}", "should_escape": False, "url": request_url}
if usage:
entry["usage_hint"] = usage
slashes.append(entry)
return {"features": {"slash_commands": slashes}}
def slack_subcommand_map() -> dict[str, str]:
"""Return name/alias -> "/command" mapping for the Slack ``/hermes`` handler, plugin commands included."""
overrides = _resolve_config_gates()
mapping: dict[str, str] = {}
for cmd in COMMAND_REGISTRY:
if _is_gateway_available(cmd, overrides):
for name in (cmd.name, *cmd.aliases):
mapping[name] = f"/{name}"
for name, _description, _args_hint in _iter_plugin_command_entries():
mapping.setdefault(name, f"/{name}")
return mapping
@@ -18,7 +18,7 @@ from unittest.mock import patch
import pytest
import hermes_cli.commands as commands_mod
import hermes_cli.commands_completion as commands_mod
def _reset_personalities_memo():
+1 -1
View File
@@ -96,7 +96,7 @@ class TestIntegration:
# scheme guard it reached _path_completions and called os.listdir on
# every keystroke. Assert no completions AND that the filesystem is
# never touched while a URL is under the cursor.
import hermes_cli.commands as commands_mod
import hermes_cli.commands_completion as commands_mod
def _fail(*_args, **_kwargs):
raise AssertionError("os.listdir must not run for a URL token")