2026-02-21 23:17:18 -08:00
|
|
|
"""Slash command definitions and autocomplete for the Hermes CLI.
|
|
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
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``.
|
2026-02-21 23:17:18 -08:00
|
|
|
"""
|
|
|
|
|
|
2026-03-07 17:53:41 -08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
2026-03-16 06:07:45 -07:00
|
|
|
import os
|
2026-03-17 01:47:32 -07:00
|
|
|
import re
|
2026-03-07 17:53:41 -08:00
|
|
|
from collections.abc import Callable, Mapping
|
chore: remove ~100 unused imports across 55 files (#3016)
Automated cleanup via pyflakes + autoflake with manual review.
Changes:
- Removed unused stdlib imports (os, sys, json, pathlib.Path, etc.)
- Removed unused typing imports (List, Dict, Any, Optional, Tuple, Set, etc.)
- Removed unused internal imports (hermes_cli.auth, hermes_cli.config, etc.)
- Fixed cli.py: removed 8 shadowed banner imports (imported from hermes_cli.banner
then immediately redefined locally — only build_welcome_banner is actually used)
- Added noqa comments to imports that appear unused but serve a purpose:
- Re-exports (gateway/session.py SessionResetPolicy, tools/terminal_tool.py
is_interrupted/_interrupt_event)
- SDK presence checks in try/except (daytona, fal_client, discord)
- Test mock targets (auxiliary_client.py Path, mcp_config.py get_hermes_home)
Zero behavioral changes. Full test suite passes (6162/6162, 2 pre-existing
streaming test failures unrelated to this change).
2026-03-25 15:02:03 -07:00
|
|
|
from dataclasses import dataclass
|
2026-03-07 17:53:41 -08:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-03-17 01:47:32 -07:00
|
|
|
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
2026-02-21 23:17:18 -08:00
|
|
|
from prompt_toolkit.completion import Completer, Completion
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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]"
|
2026-03-17 01:47:32 -07:00
|
|
|
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
2026-03-16 23:21:03 -07:00
|
|
|
cli_only: bool = False # only available in CLI
|
|
|
|
|
gateway_only: bool = False # only available in gateway/messaging
|
2026-03-26 14:41:04 -07:00
|
|
|
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
|
2026-03-16 23:21:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Central registry -- single source of truth
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
COMMAND_REGISTRY: list[CommandDef] = [
|
|
|
|
|
# Session
|
|
|
|
|
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
|
|
|
|
aliases=("reset",)),
|
|
|
|
|
CommandDef("clear", "Clear screen and start a new session", "Session",
|
|
|
|
|
cli_only=True),
|
|
|
|
|
CommandDef("history", "Show conversation history", "Session",
|
|
|
|
|
cli_only=True),
|
|
|
|
|
CommandDef("save", "Save the current conversation", "Session",
|
|
|
|
|
cli_only=True),
|
|
|
|
|
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
|
|
|
|
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
|
|
|
|
CommandDef("title", "Set a title for the current session", "Session",
|
|
|
|
|
args_hint="[name]"),
|
fix: clear ghost status-bar lines on terminal resize (#4960)
* feat: add /branch (/fork) command for session branching
Inspired by Claude Code's /branch command. Creates a copy of the current
session's conversation history in a new session, allowing the user to
explore a different approach without losing the original.
Works like 'git checkout -b' for conversations:
- /branch — auto-generates a title from the parent session
- /branch my-idea — uses a custom title
- /fork — alias for /branch
Implementation:
- CLI: _handle_branch_command() in cli.py
- Gateway: _handle_branch_command() in gateway/run.py
- CommandDef with 'fork' alias in commands.py
- Uses existing parent_session_id field in session DB
- Uses get_next_title_in_lineage() for auto-numbered branches
- 14 tests covering session creation, history copy, parent links,
title generation, edge cases, and agent sync
* fix: clear ghost status-bar lines on terminal resize
When the terminal shrinks (e.g. un-maximize), the emulator reflows
previously full-width rows (status bar, input rules) into multiple
narrower rows. prompt_toolkit's _on_resize only cursor_up()s by the
stored layout height, missing the extra rows from reflow — leaving
ghost duplicates of the status bar visible.
Fix: monkey-patch Application._on_resize to detect width shrinks,
calculate the extra rows created by reflow, and inflate the renderer's
cursor_pos.y so the erase moves up far enough to clear ghosts.
2026-04-03 22:43:45 -07:00
|
|
|
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
|
|
|
|
aliases=("fork",), args_hint="[name]"),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("compress", "Manually compress conversation context", "Session"),
|
|
|
|
|
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
|
|
|
|
args_hint="[number]"),
|
|
|
|
|
CommandDef("stop", "Kill all running background processes", "Session"),
|
fix(gateway): replace bare text approval with /approve and /deny commands (#2002)
The gateway approval system previously intercepted bare 'yes'/'no' text
from the user's next message to approve/deny dangerous commands. This was
fragile and dangerous — if the agent asked a clarify question and the user
said 'yes' to answer it, the gateway would execute the pending dangerous
command instead. (Fixes #1888)
Changes:
- Remove bare text matching ('yes', 'y', 'approve', 'ok', etc.) from
_handle_message approval check
- Add /approve and /deny as gateway-only slash commands in the command
registry
- /approve supports scoping: /approve (one-time), /approve session,
/approve always (permanent)
- Add 5-minute timeout for stale approvals
- Gateway appends structured instructions to the agent response when a
dangerous command is pending, telling the user exactly how to respond
- 9 tests covering approve, deny, timeout, scoping, and verification
that bare 'yes' no longer triggers execution
Credit to @solo386 and @FlyByNight69420 for identifying and reporting
this security issue in PR #1971 and issue #1888.
Co-authored-by: Test <test@test.com>
2026-03-18 16:58:20 -07:00
|
|
|
CommandDef("approve", "Approve a pending dangerous command", "Session",
|
|
|
|
|
gateway_only=True, args_hint="[session|always]"),
|
|
|
|
|
CommandDef("deny", "Deny a pending dangerous command", "Session",
|
|
|
|
|
gateway_only=True),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("background", "Run a prompt in the background", "Session",
|
|
|
|
|
aliases=("bg",), args_hint="<prompt>"),
|
2026-03-30 21:10:05 -07:00
|
|
|
CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
|
|
|
|
|
args_hint="<question>"),
|
2026-03-20 09:44:27 -07:00
|
|
|
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
|
|
|
|
aliases=("q",), args_hint="<prompt>"),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("status", "Show session info", "Session",
|
|
|
|
|
gateway_only=True),
|
2026-03-30 13:20:06 -07:00
|
|
|
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
|
|
|
|
gateway_only=True, aliases=("set-home",)),
|
|
|
|
|
CommandDef("resume", "Resume a previously-named session", "Session",
|
|
|
|
|
args_hint="[name]"),
|
|
|
|
|
|
|
|
|
|
# Configuration
|
|
|
|
|
CommandDef("config", "Show current configuration", "Configuration",
|
|
|
|
|
cli_only=True),
|
feat: /model command — models.dev primary database + --provider flag (#5181)
Full overhaul of the model/provider system.
## What changed
- models.dev (109 providers, 4000+ models) as primary database for provider identity AND model metadata
- --provider flag replaces colon syntax for explicit provider switching
- Full ModelInfo/ProviderInfo dataclasses with context, cost, capabilities, modalities
- HermesOverlay system merges models.dev + Hermes-specific transport/auth/aggregator flags
- User-defined endpoints via config.yaml providers: section
- /model (no args) lists authenticated providers with curated model catalog
- Rich metadata display: context window, max output, cost/M tokens, capabilities
- Config migration: custom_providers list → providers dict (v11→v12)
- AIAgent.switch_model() for in-place model swap preserving conversation
## Files
agent/models_dev.py, hermes_cli/providers.py, hermes_cli/model_switch.py,
hermes_cli/model_normalize.py, cli.py, gateway/run.py, run_agent.py,
hermes_cli/config.py, hermes_cli/commands.py
2026-04-05 01:04:44 -07:00
|
|
|
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("provider", "Show available providers and current provider",
|
|
|
|
|
"Configuration"),
|
|
|
|
|
CommandDef("prompt", "View/set custom system prompt", "Configuration",
|
2026-03-17 01:47:32 -07:00
|
|
|
cli_only=True, args_hint="[text]", subcommands=("clear",)),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("personality", "Set a predefined personality", "Configuration",
|
|
|
|
|
args_hint="[name]"),
|
2026-03-18 03:49:49 -07:00
|
|
|
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
|
|
|
|
cli_only=True, aliases=("sb",)),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
2026-03-26 14:41:04 -07:00
|
|
|
"Configuration", cli_only=True,
|
|
|
|
|
gateway_config_gate="display.tool_progress_command"),
|
2026-03-30 11:17:09 -07:00
|
|
|
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
|
|
|
|
"Configuration"),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
2026-03-17 01:47:32 -07:00
|
|
|
args_hint="[level|show|hide]",
|
|
|
|
|
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
|
|
|
|
cli_only=True, args_hint="[name]"),
|
|
|
|
|
CommandDef("voice", "Toggle voice mode", "Configuration",
|
2026-03-17 01:47:32 -07:00
|
|
|
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
2026-03-16 23:21:03 -07:00
|
|
|
|
|
|
|
|
# Tools & Skills
|
2026-03-17 02:05:26 -07:00
|
|
|
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
|
|
|
|
args_hint="[list|disable|enable] [name...]", cli_only=True),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
|
|
|
|
|
cli_only=True),
|
|
|
|
|
CommandDef("skills", "Search, install, inspect, or manage skills",
|
2026-03-17 01:47:32 -07:00
|
|
|
"Tools & Skills", cli_only=True,
|
|
|
|
|
subcommands=("search", "browse", "inspect", "install")),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
2026-03-17 01:47:32 -07:00
|
|
|
cli_only=True, args_hint="[subcommand]",
|
|
|
|
|
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
|
|
|
|
aliases=("reload_mcp",)),
|
2026-03-17 13:29:36 -07:00
|
|
|
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
|
|
|
|
cli_only=True, args_hint="[connect|disconnect|status]",
|
|
|
|
|
subcommands=("connect", "disconnect", "status")),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("plugins", "List installed plugins and their status",
|
|
|
|
|
"Tools & Skills", cli_only=True),
|
|
|
|
|
|
|
|
|
|
# Info
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
|
|
|
|
gateway_only=True, args_hint="[page]"),
|
2026-03-16 23:21:03 -07:00
|
|
|
CommandDef("help", "Show available commands", "Info"),
|
|
|
|
|
CommandDef("usage", "Show token usage for the current session", "Info"),
|
|
|
|
|
CommandDef("insights", "Show usage insights and analytics", "Info",
|
|
|
|
|
args_hint="[days]"),
|
|
|
|
|
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
|
|
|
|
cli_only=True, aliases=("gateway",)),
|
|
|
|
|
CommandDef("paste", "Check clipboard for an image and attach it", "Info",
|
|
|
|
|
cli_only=True),
|
|
|
|
|
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
|
|
|
|
|
gateway_only=True),
|
|
|
|
|
|
|
|
|
|
# Exit
|
|
|
|
|
CommandDef("quit", "Exit the CLI", "Exit",
|
|
|
|
|
cli_only=True, aliases=("exit", "q")),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-03-21 16:00:30 -07:00
|
|
|
# Derived lookups -- rebuilt once at import time, refreshed by rebuild_lookups()
|
2026-03-16 23:21:03 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
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("/"))
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 16:00:30 -07:00
|
|
|
def register_plugin_command(cmd: CommandDef) -> None:
|
|
|
|
|
"""Append a plugin-defined command to the registry and refresh lookups."""
|
|
|
|
|
COMMAND_REGISTRY.append(cmd)
|
|
|
|
|
rebuild_lookups()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rebuild_lookups() -> None:
|
|
|
|
|
"""Rebuild all derived lookup dicts from the current COMMAND_REGISTRY.
|
|
|
|
|
|
|
|
|
|
Called after plugin commands are registered so they appear in help,
|
|
|
|
|
autocomplete, gateway dispatch, Telegram menu, and Slack mapping.
|
|
|
|
|
"""
|
|
|
|
|
global GATEWAY_KNOWN_COMMANDS
|
|
|
|
|
|
|
|
|
|
_COMMAND_LOOKUP.clear()
|
|
|
|
|
_COMMAND_LOOKUP.update(_build_command_lookup())
|
|
|
|
|
|
|
|
|
|
COMMANDS.clear()
|
|
|
|
|
for cmd in COMMAND_REGISTRY:
|
|
|
|
|
if not cmd.gateway_only:
|
|
|
|
|
COMMANDS[f"/{cmd.name}"] = _build_description(cmd)
|
|
|
|
|
for alias in cmd.aliases:
|
|
|
|
|
COMMANDS[f"/{alias}"] = f"{cmd.description} (alias for /{cmd.name})"
|
|
|
|
|
|
|
|
|
|
COMMANDS_BY_CATEGORY.clear()
|
|
|
|
|
for cmd in COMMAND_REGISTRY:
|
|
|
|
|
if not cmd.gateway_only:
|
|
|
|
|
cat = COMMANDS_BY_CATEGORY.setdefault(cmd.category, {})
|
|
|
|
|
cat[f"/{cmd.name}"] = COMMANDS[f"/{cmd.name}"]
|
|
|
|
|
for alias in cmd.aliases:
|
|
|
|
|
cat[f"/{alias}"] = COMMANDS[f"/{alias}"]
|
|
|
|
|
|
|
|
|
|
SUBCOMMANDS.clear()
|
|
|
|
|
for cmd in COMMAND_REGISTRY:
|
|
|
|
|
if cmd.subcommands:
|
|
|
|
|
SUBCOMMANDS[f"/{cmd.name}"] = list(cmd.subcommands)
|
|
|
|
|
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_KNOWN_COMMANDS = frozenset(
|
|
|
|
|
name
|
|
|
|
|
for cmd in COMMAND_REGISTRY
|
2026-03-26 14:41:04 -07:00
|
|
|
if not cmd.cli_only or cmd.gateway_config_gate
|
2026-03-21 16:00:30 -07:00
|
|
|
for name in (cmd.name, *cmd.aliases)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
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
|
|
|
|
|
COMMANDS: dict[str, str] = {}
|
|
|
|
|
for _cmd in COMMAND_REGISTRY:
|
|
|
|
|
if not _cmd.gateway_only:
|
|
|
|
|
COMMANDS[f"/{_cmd.name}"] = _build_description(_cmd)
|
|
|
|
|
for _alias in _cmd.aliases:
|
|
|
|
|
COMMANDS[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
|
|
|
|
|
|
|
|
|
|
# Backwards-compatible categorized dict
|
|
|
|
|
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
|
|
|
|
|
for _cmd in COMMAND_REGISTRY:
|
|
|
|
|
if not _cmd.gateway_only:
|
|
|
|
|
_cat = COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {})
|
|
|
|
|
_cat[f"/{_cmd.name}"] = COMMANDS[f"/{_cmd.name}"]
|
|
|
|
|
for _alias in _cmd.aliases:
|
|
|
|
|
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 01:47:32 -07:00
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
# Also extract subcommands hinted in args_hint via pipe-separated patterns
|
|
|
|
|
# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
|
|
|
|
|
# NOTE: If a command already has explicit subcommands, this fallback is skipped.
|
|
|
|
|
# Use the `subcommands` field on CommandDef for intentional tab-completable args.
|
|
|
|
|
_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("|")
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Gateway helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-26 14:41:04 -07:00
|
|
|
# Set of all command names + aliases recognized by the gateway.
|
|
|
|
|
# Includes config-gated commands so the gateway can dispatch them
|
|
|
|
|
# (the handler checks the config gate at runtime).
|
2026-03-16 23:21:03 -07:00
|
|
|
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
|
|
|
|
name
|
|
|
|
|
for cmd in COMMAND_REGISTRY
|
2026-03-26 14:41:04 -07:00
|
|
|
if not cmd.cli_only or cmd.gateway_config_gate
|
2026-03-16 23:21:03 -07:00
|
|
|
for name in (cmd.name, *cmd.aliases)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-26 14:41:04 -07:00
|
|
|
def _resolve_config_gates() -> set[str]:
|
|
|
|
|
"""Return canonical names of commands whose ``gateway_config_gate`` is truthy.
|
|
|
|
|
|
|
|
|
|
Reads ``config.yaml`` and walks the dot-separated key path for each
|
|
|
|
|
config-gated command. Returns an empty set on any error so callers
|
|
|
|
|
degrade gracefully.
|
|
|
|
|
"""
|
|
|
|
|
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
|
|
|
|
|
if not gated:
|
|
|
|
|
return set()
|
|
|
|
|
try:
|
|
|
|
|
import yaml
|
|
|
|
|
config_path = os.path.join(
|
|
|
|
|
os.getenv("HERMES_HOME", os.path.expanduser("~/.hermes")),
|
|
|
|
|
"config.yaml",
|
|
|
|
|
)
|
|
|
|
|
if os.path.exists(config_path):
|
|
|
|
|
with open(config_path, encoding="utf-8") as f:
|
|
|
|
|
cfg = yaml.safe_load(f) or {}
|
|
|
|
|
else:
|
|
|
|
|
cfg = {}
|
|
|
|
|
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 val:
|
|
|
|
|
result.add(cmd.name)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
|
|
|
|
|
"""Check if *cmd* should appear in gateway surfaces (help, menus, mappings).
|
|
|
|
|
|
|
|
|
|
Unconditionally available when ``cli_only`` is False. When ``cli_only``
|
|
|
|
|
is True but ``gateway_config_gate`` is set, the command is available only
|
|
|
|
|
when the config value is truthy. Pass *config_overrides* (from
|
|
|
|
|
``_resolve_config_gates()``) to avoid re-reading config for every 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
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
def gateway_help_lines() -> list[str]:
|
|
|
|
|
"""Generate gateway help text lines from the registry."""
|
2026-03-26 14:41:04 -07:00
|
|
|
overrides = _resolve_config_gates()
|
2026-03-16 23:21:03 -07:00
|
|
|
lines: list[str] = []
|
|
|
|
|
for cmd in COMMAND_REGISTRY:
|
2026-03-26 14:41:04 -07:00
|
|
|
if not _is_gateway_available(cmd, overrides):
|
2026-03-16 23:21:03 -07:00
|
|
|
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 telegram_bot_commands() -> list[tuple[str, str]]:
|
|
|
|
|
"""Return (command_name, description) pairs for Telegram setMyCommands.
|
|
|
|
|
|
|
|
|
|
Telegram command names cannot contain hyphens, so they are replaced with
|
|
|
|
|
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
|
|
|
|
canonical command.
|
|
|
|
|
"""
|
2026-03-26 14:41:04 -07:00
|
|
|
overrides = _resolve_config_gates()
|
2026-03-16 23:21:03 -07:00
|
|
|
result: list[tuple[str, str]] = []
|
|
|
|
|
for cmd in COMMAND_REGISTRY:
|
2026-03-26 14:41:04 -07:00
|
|
|
if not _is_gateway_available(cmd, overrides):
|
2026-03-16 23:21:03 -07:00
|
|
|
continue
|
fix: sanitize Telegram command names to strip invalid characters
Telegram Bot API requires command names to contain only lowercase a-z,
digits 0-9, and underscores. Skill/plugin names containing characters
like +, /, @, or . caused set_my_commands to fail with
Bot_command_invalid.
Two-layer fix:
- scan_skill_commands(): strip non-alphanumeric/non-hyphen chars from
cmd_key at source, collapse consecutive hyphens, trim edges, skip
names that sanitize to empty string
- _sanitize_telegram_name(): centralized helper used by all 3 Telegram
name generation sites (core commands, plugin commands, skill commands)
with empty-name guard at each call site
Closes #5534
2026-04-06 20:52:04 +05:30
|
|
|
tg_name = _sanitize_telegram_name(cmd.name)
|
|
|
|
|
if tg_name:
|
|
|
|
|
result.append((tg_name, cmd.description))
|
2026-03-16 23:21:03 -07:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
_CMD_NAME_LIMIT = 32
|
|
|
|
|
"""Max command name length shared by Telegram and Discord."""
|
|
|
|
|
|
|
|
|
|
# Backward-compat alias — tests and external code may reference the old name.
|
|
|
|
|
_TG_NAME_LIMIT = _CMD_NAME_LIMIT
|
2026-03-31 02:41:50 -07:00
|
|
|
|
fix: sanitize Telegram command names to strip invalid characters
Telegram Bot API requires command names to contain only lowercase a-z,
digits 0-9, and underscores. Skill/plugin names containing characters
like +, /, @, or . caused set_my_commands to fail with
Bot_command_invalid.
Two-layer fix:
- scan_skill_commands(): strip non-alphanumeric/non-hyphen chars from
cmd_key at source, collapse consecutive hyphens, trim edges, skip
names that sanitize to empty string
- _sanitize_telegram_name(): centralized helper used by all 3 Telegram
name generation sites (core commands, plugin commands, skill commands)
with empty-name guard at each call site
Closes #5534
2026-04-06 20:52:04 +05:30
|
|
|
# Telegram Bot API allows only lowercase a-z, 0-9, and underscores in
|
|
|
|
|
# command names. This regex strips everything else after initial conversion.
|
|
|
|
|
_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]")
|
|
|
|
|
_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_telegram_name(raw: str) -> str:
|
|
|
|
|
"""Convert a command/skill/plugin name to a valid Telegram command name.
|
|
|
|
|
|
|
|
|
|
Telegram requires: 1-32 chars, lowercase a-z, digits 0-9, underscores only.
|
|
|
|
|
Steps: lowercase → replace hyphens with underscores → strip all other
|
|
|
|
|
invalid characters → collapse consecutive underscores → strip leading/
|
|
|
|
|
trailing underscores.
|
|
|
|
|
"""
|
|
|
|
|
name = raw.lower().replace("-", "_")
|
|
|
|
|
name = _TG_INVALID_CHARS.sub("", name)
|
|
|
|
|
name = _TG_MULTI_UNDERSCORE.sub("_", name)
|
|
|
|
|
return name.strip("_")
|
|
|
|
|
|
2026-03-31 02:41:50 -07:00
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
def _clamp_command_names(
|
2026-03-31 02:41:50 -07:00
|
|
|
entries: list[tuple[str, str]],
|
|
|
|
|
reserved: set[str],
|
|
|
|
|
) -> list[tuple[str, str]]:
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
"""Enforce 32-char command name limit with collision avoidance.
|
2026-03-31 02:41:50 -07:00
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
Both Telegram and Discord cap slash command names at 32 characters.
|
|
|
|
|
Names exceeding the limit are truncated. If truncation creates a duplicate
|
2026-03-31 02:41:50 -07:00
|
|
|
(against *reserved* names or earlier entries in the same batch), the name is
|
|
|
|
|
shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.
|
|
|
|
|
If all 10 digit slots are taken the entry is silently dropped.
|
|
|
|
|
"""
|
|
|
|
|
used: set[str] = set(reserved)
|
|
|
|
|
result: list[tuple[str, str]] = []
|
|
|
|
|
for name, desc in entries:
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
if len(name) > _CMD_NAME_LIMIT:
|
|
|
|
|
candidate = name[:_CMD_NAME_LIMIT]
|
2026-03-31 02:41:50 -07:00
|
|
|
if candidate in used:
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
prefix = name[:_CMD_NAME_LIMIT - 1]
|
2026-03-31 02:41:50 -07:00
|
|
|
for digit in range(10):
|
|
|
|
|
candidate = f"{prefix}{digit}"
|
|
|
|
|
if candidate not in used:
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
# All 10 digit slots exhausted — skip entry
|
|
|
|
|
continue
|
|
|
|
|
name = candidate
|
|
|
|
|
if name in used:
|
|
|
|
|
continue
|
|
|
|
|
used.add(name)
|
|
|
|
|
result.append((name, desc))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
# Backward-compat alias.
|
|
|
|
|
_clamp_telegram_names = _clamp_command_names
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
|
2026-03-30 13:04:06 -07:00
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Shared skill/plugin collection for gateway platforms
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _collect_gateway_skill_entries(
|
|
|
|
|
platform: str,
|
|
|
|
|
max_slots: int,
|
|
|
|
|
reserved_names: set[str],
|
|
|
|
|
desc_limit: int = 100,
|
|
|
|
|
sanitize_name: "Callable[[str], str] | None" = None,
|
|
|
|
|
) -> tuple[list[tuple[str, str, str]], int]:
|
|
|
|
|
"""Collect plugin + skill entries for a gateway platform.
|
|
|
|
|
|
|
|
|
|
Priority order:
|
|
|
|
|
1. Plugin slash commands (take precedence over skills)
|
|
|
|
|
2. Built-in skill commands (fill remaining slots, alphabetical)
|
|
|
|
|
|
|
|
|
|
Only skills are trimmed when the cap is reached.
|
|
|
|
|
Hub-installed skills are excluded. Per-platform disabled skills are
|
|
|
|
|
excluded.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
platform: Platform identifier for per-platform skill filtering
|
|
|
|
|
(``"telegram"``, ``"discord"``, etc.).
|
|
|
|
|
max_slots: Maximum number of entries to return (remaining slots after
|
|
|
|
|
built-in/core commands).
|
|
|
|
|
reserved_names: Names already taken by built-in commands. Mutated
|
|
|
|
|
in-place as new names are added.
|
|
|
|
|
desc_limit: Max description length (40 for Telegram, 100 for Discord).
|
|
|
|
|
sanitize_name: Optional name transform applied before clamping, e.g.
|
|
|
|
|
:func:`_sanitize_telegram_name` for Telegram. May return an
|
|
|
|
|
empty string to signal "skip this entry".
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
|
|
|
|
|
Returns:
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
``(entries, hidden_count)`` where *entries* is a list of
|
|
|
|
|
``(name, description, cmd_key)`` triples and *hidden_count* is the
|
|
|
|
|
number of skill entries dropped due to the cap. ``cmd_key`` is the
|
|
|
|
|
original ``/skill-name`` key from :func:`get_skill_commands`.
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
"""
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
all_entries: list[tuple[str, str, str]] = []
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
|
|
|
|
|
plugin_pairs: list[tuple[str, str]] = []
|
2026-03-30 13:04:06 -07:00
|
|
|
try:
|
|
|
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
|
pm = get_plugin_manager()
|
|
|
|
|
plugin_cmds = getattr(pm, "_plugin_commands", {})
|
|
|
|
|
for cmd_name in sorted(plugin_cmds):
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
|
|
|
|
if not name:
|
fix: sanitize Telegram command names to strip invalid characters
Telegram Bot API requires command names to contain only lowercase a-z,
digits 0-9, and underscores. Skill/plugin names containing characters
like +, /, @, or . caused set_my_commands to fail with
Bot_command_invalid.
Two-layer fix:
- scan_skill_commands(): strip non-alphanumeric/non-hyphen chars from
cmd_key at source, collapse consecutive hyphens, trim edges, skip
names that sanitize to empty string
- _sanitize_telegram_name(): centralized helper used by all 3 Telegram
name generation sites (core commands, plugin commands, skill commands)
with empty-name guard at each call site
Closes #5534
2026-04-06 20:52:04 +05:30
|
|
|
continue
|
2026-03-30 13:04:06 -07:00
|
|
|
desc = "Plugin command"
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
if len(desc) > desc_limit:
|
|
|
|
|
desc = desc[:desc_limit - 3] + "..."
|
|
|
|
|
plugin_pairs.append((name, desc))
|
2026-03-30 13:04:06 -07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names)
|
|
|
|
|
reserved_names.update(n for n, _ in plugin_pairs)
|
|
|
|
|
# Plugins have no cmd_key — use empty string as placeholder
|
|
|
|
|
for n, d in plugin_pairs:
|
|
|
|
|
all_entries.append((n, d, ""))
|
2026-03-31 02:41:50 -07:00
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
|
2026-04-03 10:10:53 -07:00
|
|
|
_platform_disabled: set[str] = set()
|
|
|
|
|
try:
|
|
|
|
|
from agent.skill_utils import get_disabled_skill_names
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
_platform_disabled = get_disabled_skill_names(platform=platform)
|
2026-04-03 10:10:53 -07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
skill_triples: list[tuple[str, str, str]] = []
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
try:
|
|
|
|
|
from agent.skill_commands import get_skill_commands
|
2026-03-30 11:01:13 -07:00
|
|
|
from tools.skills_tool import SKILLS_DIR
|
|
|
|
|
_skills_dir = str(SKILLS_DIR.resolve())
|
|
|
|
|
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
skill_cmds = get_skill_commands()
|
|
|
|
|
for cmd_key in sorted(skill_cmds):
|
|
|
|
|
info = skill_cmds[cmd_key]
|
|
|
|
|
skill_path = info.get("skill_md_path", "")
|
2026-03-30 11:01:13 -07:00
|
|
|
if not skill_path.startswith(_skills_dir):
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
continue
|
2026-03-30 11:01:13 -07:00
|
|
|
if skill_path.startswith(_hub_dir):
|
2026-03-30 13:04:06 -07:00
|
|
|
continue
|
2026-04-03 10:10:53 -07:00
|
|
|
skill_name = info.get("name", "")
|
|
|
|
|
if skill_name in _platform_disabled:
|
|
|
|
|
continue
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
raw_name = cmd_key.lstrip("/")
|
|
|
|
|
name = sanitize_name(raw_name) if sanitize_name else raw_name
|
fix: sanitize Telegram command names to strip invalid characters
Telegram Bot API requires command names to contain only lowercase a-z,
digits 0-9, and underscores. Skill/plugin names containing characters
like +, /, @, or . caused set_my_commands to fail with
Bot_command_invalid.
Two-layer fix:
- scan_skill_commands(): strip non-alphanumeric/non-hyphen chars from
cmd_key at source, collapse consecutive hyphens, trim edges, skip
names that sanitize to empty string
- _sanitize_telegram_name(): centralized helper used by all 3 Telegram
name generation sites (core commands, plugin commands, skill commands)
with empty-name guard at each call site
Closes #5534
2026-04-06 20:52:04 +05:30
|
|
|
if not name:
|
|
|
|
|
continue
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
desc = info.get("description", "")
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
if len(desc) > desc_limit:
|
|
|
|
|
desc = desc[:desc_limit - 3] + "..."
|
|
|
|
|
skill_triples.append((name, desc, cmd_key))
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
# Clamp names; _clamp_command_names works on (name, desc) pairs so we
|
|
|
|
|
# need to zip/unzip.
|
|
|
|
|
skill_pairs = [(n, d) for n, d, _ in skill_triples]
|
|
|
|
|
key_by_pair = {(n, d): k for n, d, k in skill_triples}
|
|
|
|
|
skill_pairs = _clamp_command_names(skill_pairs, reserved_names)
|
|
|
|
|
|
|
|
|
|
# Skills fill remaining slots — only tier that gets trimmed
|
|
|
|
|
remaining = max(0, max_slots - len(all_entries))
|
|
|
|
|
hidden_count = max(0, len(skill_pairs) - remaining)
|
|
|
|
|
for n, d in skill_pairs[:remaining]:
|
|
|
|
|
all_entries.append((n, d, key_by_pair.get((n, d), "")))
|
|
|
|
|
|
|
|
|
|
return all_entries[:max_slots], hidden_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Platform-specific wrappers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
|
|
|
|
"""Return Telegram menu commands capped to the Bot API limit.
|
|
|
|
|
|
|
|
|
|
Priority order (higher priority = never bumped by overflow):
|
|
|
|
|
1. Core CommandDef commands (always included)
|
|
|
|
|
2. Plugin slash commands (take precedence over skills)
|
|
|
|
|
3. Built-in skill commands (fill remaining slots, alphabetical)
|
|
|
|
|
|
|
|
|
|
Skills are the only tier that gets trimmed when the cap is hit.
|
|
|
|
|
User-installed hub skills are excluded — accessible via /skills.
|
|
|
|
|
Skills disabled for the ``"telegram"`` platform (via ``hermes skills
|
|
|
|
|
config``) are excluded from the menu entirely.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
(menu_commands, hidden_count) where hidden_count is the number of
|
|
|
|
|
skill commands omitted due to the cap.
|
|
|
|
|
"""
|
|
|
|
|
core_commands = list(telegram_bot_commands())
|
|
|
|
|
reserved_names = {n for n, _ in core_commands}
|
|
|
|
|
all_commands = list(core_commands)
|
2026-03-31 02:41:50 -07:00
|
|
|
|
2026-03-30 13:04:06 -07:00
|
|
|
remaining_slots = max(0, max_commands - len(all_commands))
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
entries, hidden_count = _collect_gateway_skill_entries(
|
|
|
|
|
platform="telegram",
|
|
|
|
|
max_slots=remaining_slots,
|
|
|
|
|
reserved_names=reserved_names,
|
|
|
|
|
desc_limit=40,
|
|
|
|
|
sanitize_name=_sanitize_telegram_name,
|
|
|
|
|
)
|
|
|
|
|
# Drop the cmd_key — Telegram only needs (name, desc) pairs.
|
|
|
|
|
all_commands.extend((n, d) for n, d, _k in entries)
|
feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)
* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap
Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:
1. Telegram menu now includes active skill commands alongside built-in
commands, capped at 100 entries (Telegram Bot API limit). Overflow
commands remain callable but hidden from the picker. Logged at
startup when cap is hit.
2. New /commands [page] gateway command for paginated browsing of all
commands + skills. /help now shows first 10 skill commands and
points to /commands for the full list.
3. When a user types a slash command that matches a disabled or
uninstalled skill, they get actionable guidance:
- Disabled: 'Enable it with: hermes skills config'
- Optional (not installed): 'Install with: hermes skills install official/<path>'
Built on ideas from PR #3921 by @kshitijk4poor.
* chore: move 21 niche skills to optional-skills
Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>
Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
hermes-atropos-environments, huggingface-tokenizers, instructor,
lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli
Built-in skills: 96 → 75
Optional skills: 22 → 43
* fix: only include repo built-in skills in Telegram menu, not user-installed
User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.
This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
2026-03-30 10:57:30 -07:00
|
|
|
return all_commands[:max_commands], hidden_count
|
|
|
|
|
|
|
|
|
|
|
feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:
1. Core/built-in commands (never trimmed)
2. Plugin commands (never trimmed)
3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)
Changes:
hermes_cli/commands.py:
- Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
- Rename _clamp_telegram_names → _clamp_command_names (generic)
- Extract _collect_gateway_skill_entries() — shared plugin + skill
collection with platform filtering, name sanitization, description
truncation, and cap enforcement
- Refactor telegram_menu_commands() to use the shared helper
- Add discord_skill_commands() that returns (name, desc, cmd_key) triples
- Preserve _sanitize_telegram_name() for Telegram-specific name cleaning
gateway/platforms/discord.py:
- Call discord_skill_commands() from _register_slash_commands()
- Create app_commands.Command per skill entry with cmd_key callback
- Respect 100-command global Discord limit
- Log warning when skills are skipped due to cap
Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.
Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.
Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
|
|
|
def discord_skill_commands(
|
|
|
|
|
max_slots: int,
|
|
|
|
|
reserved_names: set[str],
|
|
|
|
|
) -> tuple[list[tuple[str, str, str]], int]:
|
|
|
|
|
"""Return skill entries for Discord slash command registration.
|
|
|
|
|
|
|
|
|
|
Same priority and filtering logic as :func:`telegram_menu_commands`
|
|
|
|
|
(plugins > skills, hub excluded, per-platform disabled excluded), but
|
|
|
|
|
adapted for Discord's constraints:
|
|
|
|
|
|
|
|
|
|
- Hyphens are allowed in names (no ``-`` → ``_`` sanitization)
|
|
|
|
|
- Descriptions capped at 100 chars (Discord's per-field max)
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
max_slots: Available command slots (100 minus existing built-in count).
|
|
|
|
|
reserved_names: Names of already-registered built-in commands.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
``(entries, hidden_count)`` where *entries* is a list of
|
|
|
|
|
``(discord_name, description, cmd_key)`` triples. ``cmd_key`` is
|
|
|
|
|
the original ``/skill-name`` key needed for the slash handler callback.
|
|
|
|
|
"""
|
|
|
|
|
return _collect_gateway_skill_entries(
|
|
|
|
|
platform="discord",
|
|
|
|
|
max_slots=max_slots,
|
|
|
|
|
reserved_names=set(reserved_names), # copy — don't mutate caller's set
|
|
|
|
|
desc_limit=100,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
def slack_subcommand_map() -> dict[str, str]:
|
|
|
|
|
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
|
|
|
|
|
|
|
|
|
Maps both canonical names and aliases so /hermes bg do stuff works
|
|
|
|
|
the same as /hermes background do stuff.
|
|
|
|
|
"""
|
2026-03-26 14:41:04 -07:00
|
|
|
overrides = _resolve_config_gates()
|
2026-03-16 23:21:03 -07:00
|
|
|
mapping: dict[str, str] = {}
|
|
|
|
|
for cmd in COMMAND_REGISTRY:
|
2026-03-26 14:41:04 -07:00
|
|
|
if not _is_gateway_available(cmd, overrides):
|
2026-03-16 23:21:03 -07:00
|
|
|
continue
|
|
|
|
|
mapping[cmd.name] = f"/{cmd.name}"
|
|
|
|
|
for alias in cmd.aliases:
|
|
|
|
|
mapping[alias] = f"/{alias}"
|
|
|
|
|
return mapping
|
|
|
|
|
|
2026-03-09 03:59:47 -04:00
|
|
|
|
2026-03-16 23:21:03 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Autocomplete
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-02-21 23:17:18 -08:00
|
|
|
|
|
|
|
|
class SlashCommandCompleter(Completer):
|
2026-03-17 01:47:32 -07:00
|
|
|
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
2026-03-07 17:53:41 -08:00
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
self._skill_commands_provider = skill_commands_provider
|
|
|
|
|
|
|
|
|
|
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
|
|
|
|
|
if self._skill_commands_provider is None:
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
return self._skill_commands_provider() or {}
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _completion_text(cmd_name: str, word: str) -> str:
|
|
|
|
|
"""Return replacement text for a completion.
|
|
|
|
|
|
|
|
|
|
When the user has already typed the full command exactly (``/help``),
|
|
|
|
|
returning ``help`` would be a no-op and prompt_toolkit suppresses the
|
|
|
|
|
menu. Appending a trailing space keeps the dropdown visible and makes
|
|
|
|
|
backspacing retrigger it naturally.
|
|
|
|
|
"""
|
|
|
|
|
return f"{cmd_name} " if cmd_name == word else cmd_name
|
2026-02-21 23:17:18 -08:00
|
|
|
|
feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
2026-03-16 06:07:45 -07:00
|
|
|
@staticmethod
|
|
|
|
|
def _extract_path_word(text: str) -> str | None:
|
|
|
|
|
"""Extract the current word if it looks like a file path.
|
|
|
|
|
|
|
|
|
|
Returns the path-like token under the cursor, or None if the
|
|
|
|
|
current word doesn't look like a path. A word is path-like when
|
|
|
|
|
it starts with ``./``, ``../``, ``~/``, ``/``, or contains a
|
|
|
|
|
``/`` separator (e.g. ``src/main.py``).
|
|
|
|
|
"""
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
# Walk backwards to find the start of the current "word".
|
|
|
|
|
# Words are delimited by spaces, but paths can contain almost anything.
|
|
|
|
|
i = len(text) - 1
|
|
|
|
|
while i >= 0 and text[i] != " ":
|
|
|
|
|
i -= 1
|
|
|
|
|
word = text[i + 1:]
|
|
|
|
|
if not word:
|
|
|
|
|
return None
|
|
|
|
|
# Only trigger path completion for path-like tokens
|
|
|
|
|
if word.startswith(("./", "../", "~/", "/")) or "/" in word:
|
|
|
|
|
return word
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _path_completions(word: str, limit: int = 30):
|
|
|
|
|
"""Yield Completion objects for file paths matching *word*."""
|
|
|
|
|
expanded = os.path.expanduser(word)
|
|
|
|
|
# Split into directory part and prefix to match inside it
|
|
|
|
|
if expanded.endswith("/"):
|
|
|
|
|
search_dir = expanded
|
|
|
|
|
prefix = ""
|
|
|
|
|
else:
|
|
|
|
|
search_dir = os.path.dirname(expanded) or "."
|
|
|
|
|
prefix = os.path.basename(expanded)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
entries = os.listdir(search_dir)
|
|
|
|
|
except OSError:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
prefix_lower = prefix.lower()
|
|
|
|
|
for entry in sorted(entries):
|
|
|
|
|
if prefix and not entry.lower().startswith(prefix_lower):
|
|
|
|
|
continue
|
|
|
|
|
if count >= limit:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
full_path = os.path.join(search_dir, entry)
|
|
|
|
|
is_dir = os.path.isdir(full_path)
|
|
|
|
|
|
|
|
|
|
# Build the completion text (what replaces the typed word)
|
|
|
|
|
if word.startswith("~"):
|
|
|
|
|
display_path = "~/" + os.path.relpath(full_path, os.path.expanduser("~"))
|
|
|
|
|
elif os.path.isabs(word):
|
|
|
|
|
display_path = full_path
|
|
|
|
|
else:
|
|
|
|
|
# Keep relative
|
|
|
|
|
display_path = os.path.relpath(full_path)
|
|
|
|
|
|
|
|
|
|
if is_dir:
|
|
|
|
|
display_path += "/"
|
|
|
|
|
|
|
|
|
|
suffix = "/" if is_dir else ""
|
|
|
|
|
meta = "dir" if is_dir else _file_size_label(full_path)
|
|
|
|
|
|
|
|
|
|
yield Completion(
|
|
|
|
|
display_path,
|
|
|
|
|
start_position=-len(word),
|
|
|
|
|
display=entry + suffix,
|
|
|
|
|
display_meta=meta,
|
|
|
|
|
)
|
|
|
|
|
count += 1
|
|
|
|
|
|
2026-03-22 05:32:04 -07:00
|
|
|
@staticmethod
|
|
|
|
|
def _extract_context_word(text: str) -> str | None:
|
|
|
|
|
"""Extract a bare ``@`` token for context reference completions."""
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
# Walk backwards to find the start of the current word
|
|
|
|
|
i = len(text) - 1
|
|
|
|
|
while i >= 0 and text[i] != " ":
|
|
|
|
|
i -= 1
|
|
|
|
|
word = text[i + 1:]
|
|
|
|
|
if not word.startswith("@"):
|
|
|
|
|
return None
|
|
|
|
|
return word
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _context_completions(word: str, limit: int = 30):
|
|
|
|
|
"""Yield Claude Code-style @ context completions.
|
|
|
|
|
|
|
|
|
|
Bare ``@`` or ``@partial`` shows static references and matching
|
|
|
|
|
files/folders. ``@file:path`` and ``@folder:path`` are handled
|
|
|
|
|
by the existing path completion path.
|
|
|
|
|
"""
|
|
|
|
|
lowered = word.lower()
|
|
|
|
|
|
|
|
|
|
# Static context references
|
|
|
|
|
_STATIC_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"),
|
|
|
|
|
)
|
|
|
|
|
for candidate, meta in _STATIC_REFS:
|
|
|
|
|
if candidate.lower().startswith(lowered) and candidate.lower() != lowered:
|
|
|
|
|
yield Completion(
|
|
|
|
|
candidate,
|
|
|
|
|
start_position=-len(word),
|
|
|
|
|
display=candidate,
|
|
|
|
|
display_meta=meta,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# If the user typed @file: or @folder:, delegate to path completions
|
|
|
|
|
for prefix in ("@file:", "@folder:"):
|
|
|
|
|
if word.startswith(prefix):
|
|
|
|
|
path_part = word[len(prefix):] or "."
|
|
|
|
|
expanded = os.path.expanduser(path_part)
|
|
|
|
|
if expanded.endswith("/"):
|
|
|
|
|
search_dir, match_prefix = expanded, ""
|
|
|
|
|
else:
|
|
|
|
|
search_dir = os.path.dirname(expanded) or "."
|
|
|
|
|
match_prefix = os.path.basename(expanded)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
entries = os.listdir(search_dir)
|
|
|
|
|
except OSError:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
prefix_lower = match_prefix.lower()
|
|
|
|
|
for entry in sorted(entries):
|
|
|
|
|
if match_prefix and not entry.lower().startswith(prefix_lower):
|
|
|
|
|
continue
|
|
|
|
|
if count >= limit:
|
|
|
|
|
break
|
|
|
|
|
full_path = os.path.join(search_dir, entry)
|
|
|
|
|
is_dir = os.path.isdir(full_path)
|
|
|
|
|
display_path = os.path.relpath(full_path)
|
|
|
|
|
suffix = "/" if is_dir else ""
|
|
|
|
|
kind = "folder" if is_dir else "file"
|
|
|
|
|
meta = "dir" if is_dir else _file_size_label(full_path)
|
|
|
|
|
completion = f"@{kind}:{display_path}{suffix}"
|
|
|
|
|
yield Completion(
|
|
|
|
|
completion,
|
|
|
|
|
start_position=-len(word),
|
|
|
|
|
display=entry + suffix,
|
|
|
|
|
display_meta=meta,
|
|
|
|
|
)
|
|
|
|
|
count += 1
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Bare @ or @partial — show matching files/folders from cwd
|
|
|
|
|
query = word[1:] # strip the @
|
|
|
|
|
if not query:
|
|
|
|
|
search_dir, match_prefix = ".", ""
|
|
|
|
|
else:
|
|
|
|
|
expanded = os.path.expanduser(query)
|
|
|
|
|
if expanded.endswith("/"):
|
|
|
|
|
search_dir, match_prefix = expanded, ""
|
|
|
|
|
else:
|
|
|
|
|
search_dir = os.path.dirname(expanded) or "."
|
|
|
|
|
match_prefix = os.path.basename(expanded)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
entries = os.listdir(search_dir)
|
|
|
|
|
except OSError:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
prefix_lower = match_prefix.lower()
|
|
|
|
|
for entry in sorted(entries):
|
|
|
|
|
if match_prefix and not entry.lower().startswith(prefix_lower):
|
|
|
|
|
continue
|
|
|
|
|
if entry.startswith("."):
|
|
|
|
|
continue # skip hidden files in bare @ mode
|
|
|
|
|
if count >= limit:
|
|
|
|
|
break
|
|
|
|
|
full_path = os.path.join(search_dir, entry)
|
|
|
|
|
is_dir = os.path.isdir(full_path)
|
|
|
|
|
display_path = os.path.relpath(full_path)
|
|
|
|
|
suffix = "/" if is_dir else ""
|
|
|
|
|
kind = "folder" if is_dir else "file"
|
|
|
|
|
meta = "dir" if is_dir else _file_size_label(full_path)
|
|
|
|
|
completion = f"@{kind}:{display_path}{suffix}"
|
|
|
|
|
yield Completion(
|
|
|
|
|
completion,
|
|
|
|
|
start_position=-len(word),
|
|
|
|
|
display=entry + suffix,
|
|
|
|
|
display_meta=meta,
|
|
|
|
|
)
|
|
|
|
|
count += 1
|
|
|
|
|
|
2026-04-05 10:58:44 -07:00
|
|
|
def _model_completions(self, sub_text: str, sub_lower: str):
|
|
|
|
|
"""Yield completions for /model from config aliases + built-in aliases."""
|
|
|
|
|
seen = set()
|
|
|
|
|
# Config-based direct aliases (preferred — include provider info)
|
|
|
|
|
try:
|
|
|
|
|
from hermes_cli.model_switch import (
|
|
|
|
|
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
|
|
|
|
)
|
|
|
|
|
_ensure_direct_aliases()
|
|
|
|
|
for name, da in DIRECT_ALIASES.items():
|
|
|
|
|
if name.startswith(sub_lower) and name != sub_lower:
|
|
|
|
|
seen.add(name)
|
|
|
|
|
yield Completion(
|
|
|
|
|
name,
|
|
|
|
|
start_position=-len(sub_text),
|
|
|
|
|
display=name,
|
|
|
|
|
display_meta=f"{da.model} ({da.provider})",
|
|
|
|
|
)
|
|
|
|
|
# Built-in catalog aliases not already covered
|
|
|
|
|
for name in sorted(MODEL_ALIASES.keys()):
|
|
|
|
|
if name in seen:
|
|
|
|
|
continue
|
|
|
|
|
if name.startswith(sub_lower) and name != sub_lower:
|
|
|
|
|
identity = MODEL_ALIASES[name]
|
|
|
|
|
yield Completion(
|
|
|
|
|
name,
|
|
|
|
|
start_position=-len(sub_text),
|
|
|
|
|
display=name,
|
|
|
|
|
display_meta=f"{identity.vendor}/{identity.family}",
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-02-21 23:17:18 -08:00
|
|
|
def get_completions(self, document, complete_event):
|
|
|
|
|
text = document.text_before_cursor
|
|
|
|
|
if not text.startswith("/"):
|
2026-03-22 05:32:04 -07:00
|
|
|
# Try @ context completion (Claude Code-style)
|
|
|
|
|
ctx_word = self._extract_context_word(text)
|
|
|
|
|
if ctx_word is not None:
|
|
|
|
|
yield from self._context_completions(ctx_word)
|
|
|
|
|
return
|
feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
2026-03-16 06:07:45 -07:00
|
|
|
# Try file path completion for non-slash input
|
|
|
|
|
path_word = self._extract_path_word(text)
|
|
|
|
|
if path_word is not None:
|
|
|
|
|
yield from self._path_completions(path_word)
|
2026-02-21 23:17:18 -08:00
|
|
|
return
|
2026-03-07 17:53:41 -08:00
|
|
|
|
2026-03-17 01:47:32 -07:00
|
|
|
# Check if we're completing a subcommand (base command already typed)
|
|
|
|
|
parts = text.split(maxsplit=1)
|
|
|
|
|
base_cmd = parts[0].lower()
|
|
|
|
|
if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
|
|
|
|
|
sub_text = parts[1] if len(parts) > 1 else ""
|
|
|
|
|
sub_lower = sub_text.lower()
|
|
|
|
|
|
2026-04-05 10:58:44 -07:00
|
|
|
# Dynamic model alias completions for /model
|
|
|
|
|
if " " not in sub_text and base_cmd == "/model":
|
|
|
|
|
yield from self._model_completions(sub_text, sub_lower)
|
|
|
|
|
return
|
|
|
|
|
|
2026-03-17 01:47:32 -07:00
|
|
|
# Static subcommand completions
|
|
|
|
|
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
|
|
|
|
for sub in SUBCOMMANDS[base_cmd]:
|
|
|
|
|
if sub.startswith(sub_lower) and sub != sub_lower:
|
|
|
|
|
yield Completion(
|
|
|
|
|
sub,
|
|
|
|
|
start_position=-len(sub_text),
|
|
|
|
|
display=sub,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2026-02-21 23:17:18 -08:00
|
|
|
word = text[1:]
|
2026-03-07 17:53:41 -08:00
|
|
|
|
2026-02-21 23:17:18 -08:00
|
|
|
for cmd, desc in COMMANDS.items():
|
|
|
|
|
cmd_name = cmd[1:]
|
|
|
|
|
if cmd_name.startswith(word):
|
|
|
|
|
yield Completion(
|
2026-03-07 17:53:41 -08:00
|
|
|
self._completion_text(cmd_name, word),
|
2026-02-21 23:17:18 -08:00
|
|
|
start_position=-len(word),
|
|
|
|
|
display=cmd,
|
|
|
|
|
display_meta=desc,
|
|
|
|
|
)
|
2026-03-07 17:53:41 -08:00
|
|
|
|
|
|
|
|
for cmd, info in self._iter_skill_commands().items():
|
|
|
|
|
cmd_name = cmd[1:]
|
|
|
|
|
if cmd_name.startswith(word):
|
|
|
|
|
description = str(info.get("description", "Skill command"))
|
|
|
|
|
short_desc = description[:50] + ("..." if len(description) > 50 else "")
|
|
|
|
|
yield Completion(
|
|
|
|
|
self._completion_text(cmd_name, word),
|
|
|
|
|
start_position=-len(word),
|
|
|
|
|
display=cmd,
|
|
|
|
|
display_meta=f"⚡ {short_desc}",
|
|
|
|
|
)
|
feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
2026-03-16 06:07:45 -07:00
|
|
|
|
|
|
|
|
|
2026-03-17 01:47:32 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Inline auto-suggest (ghost text) for slash commands
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class SlashCommandAutoSuggest(AutoSuggest):
|
|
|
|
|
"""Inline ghost-text suggestions for slash commands and their subcommands.
|
|
|
|
|
|
|
|
|
|
Shows the rest of a command or subcommand in dim text as you type.
|
|
|
|
|
Falls back to history-based suggestions for non-slash 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 get_suggestion(self, buffer, document):
|
|
|
|
|
text = document.text_before_cursor
|
|
|
|
|
|
|
|
|
|
# Only suggest for slash commands
|
|
|
|
|
if not text.startswith("/"):
|
|
|
|
|
# Fall back to history for regular text
|
|
|
|
|
if self._history:
|
|
|
|
|
return self._history.get_suggestion(buffer, document)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
parts = text.split(maxsplit=1)
|
|
|
|
|
base_cmd = parts[0].lower()
|
|
|
|
|
|
|
|
|
|
if len(parts) == 1 and not text.endswith(" "):
|
|
|
|
|
# Still typing the command name: /upd → suggest "ate"
|
|
|
|
|
word = text[1:].lower()
|
|
|
|
|
for cmd in COMMANDS:
|
|
|
|
|
cmd_name = cmd[1:] # strip leading /
|
|
|
|
|
if cmd_name.startswith(word) and cmd_name != word:
|
|
|
|
|
return Suggestion(cmd_name[len(word):])
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Command is complete — suggest subcommands or model names
|
|
|
|
|
sub_text = parts[1] if len(parts) > 1 else ""
|
|
|
|
|
sub_lower = sub_text.lower()
|
|
|
|
|
|
|
|
|
|
# Static subcommands
|
|
|
|
|
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
|
|
|
|
|
if " " not in sub_text:
|
|
|
|
|
for sub in SUBCOMMANDS[base_cmd]:
|
|
|
|
|
if sub.startswith(sub_lower) and sub != sub_lower:
|
|
|
|
|
return Suggestion(sub[len(sub_text):])
|
|
|
|
|
|
|
|
|
|
# Fall back to history
|
|
|
|
|
if self._history:
|
|
|
|
|
return self._history.get_suggestion(buffer, document)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
feat(cli): add file path autocomplete in the input prompt (#1545)
When typing a path-like token (./ ../ ~/ / or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.
Triggered by tokens like:
edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ...
check ~/doc → shows ~/docs/, ~/documents/, ...
read /etc/hos → shows /etc/hosts, /etc/hostname, ...
open tools/reg → shows tools/registry.py
Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.
Inspired by OpenCode PR #145 (file path completion menu).
Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
tokens, _path_completions() yields filesystem Completions with
size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
path extraction, prefix filtering, directory markers, home
expansion, case-insensitivity, integration with slash commands
2026-03-16 06:07:45 -07:00
|
|
|
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"
|