- Drop dead code: _telegram_effective_priority, _prioritize_telegram_menu_commands, discord_skill_commands, _TG_NAME_LIMIT/_clamp_telegram_names compat aliases, the empty _SLACK_PRIORITY_ALIASES pinning pass (and tests that only pinned them). - Unify the Telegram and Discord skill collectors behind _iter_gateway_skills (one eligibility/root-matching implementation) and _truncate_desc. - Table-drive Telegram menu priority modes (_TELEGRAM_PRIORITY_TIERS) and the completer's per-command dynamic completions (_DYNAMIC_COMPLETIONS). - Unify path and @file:/@folder: directory-listing completions (_dir_completions), command-completion construction (_short_desc), /tools candidate rows, the Slack canonical/alias passes and the derived COMMANDS/COMMANDS_BY_CATEGORY loops. - Compact docstrings/comments, keeping every rule, invariant and rationale. Behavior-neutral: registry-derived outputs, menus, manifests and completions byte-identical against origin/main on a fixture sweep.
1922 lines
86 KiB
Python
1922 lines
86 KiB
Python
"""Slash command definitions and autocomplete for the Hermes CLI.
|
|
|
|
Central registry for all slash commands. Every consumer -- CLI help, gateway
|
|
dispatch, Telegram BotCommands, Slack subcommand mapping, autocomplete --
|
|
derives its data from ``COMMAND_REGISTRY``.
|
|
|
|
To add a command: add a ``CommandDef`` entry to ``COMMAND_REGISTRY``.
|
|
To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from utils import is_truthy_value
|
|
from hermes_constants import INDICATOR_STYLES
|
|
|
|
# (config-file signature, personalities) memo for /personality completion.
|
|
_personalities_memo: Optional[
|
|
Tuple[Tuple[Optional[str], Optional[int], Optional[int]], Dict[str, Any]]
|
|
] = None
|
|
|
|
|
|
def _personalities_from_cli_config() -> Dict[str, Any]:
|
|
"""Return the available personalities map, memoised on config mtime.
|
|
|
|
Wraps ``available_personalities(load_cli_config())`` — the single owner of
|
|
built-ins + user overrides. load_cli_config() does a full YAML parse + deep
|
|
merge on every call and the completer runs per keystroke; the result only
|
|
changes when config.yaml changes on disk, so keying on path+mtime+size
|
|
keeps the memo freshness-correct (same pattern as load_env). Falls back to
|
|
a fresh load when the file cannot be stat'ed.
|
|
"""
|
|
global _personalities_memo
|
|
from cli import load_cli_config
|
|
from hermes_cli.personality import available_personalities
|
|
|
|
try:
|
|
from hermes_cli.config import get_config_path
|
|
|
|
cfg_path = get_config_path()
|
|
st = cfg_path.stat()
|
|
sig = (str(cfg_path), st.st_mtime_ns, st.st_size)
|
|
except Exception:
|
|
sig = (None, None, None)
|
|
|
|
if _personalities_memo is not None and _personalities_memo[0] == sig:
|
|
return _personalities_memo[1]
|
|
|
|
personalities = available_personalities(load_cli_config())
|
|
_personalities_memo = (sig, personalities)
|
|
return personalities
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# prompt_toolkit is optional (only the completer/auto-suggest need it); the
|
|
# gateway must still import this module for the registry without it.
|
|
try:
|
|
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
|
from prompt_toolkit.completion import Completer, Completion
|
|
except ImportError: # pragma: no cover
|
|
AutoSuggest = object # type: ignore[assignment,misc]
|
|
Completer = object # type: ignore[assignment,misc]
|
|
Suggestion = None # type: ignore[assignment]
|
|
Completion = None # type: ignore[assignment]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CommandDef dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandDef:
|
|
"""Definition of a single slash command."""
|
|
|
|
name: str # canonical name without slash: "background"
|
|
description: str # human-readable description
|
|
category: str # "Session", "Configuration", etc.
|
|
aliases: tuple[str, ...] = () # alternative names: ("bg",)
|
|
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
|
|
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
|
cli_only: bool = False # only available in CLI
|
|
gateway_only: bool = False # only available in gateway/messaging
|
|
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
|
|
# Mid-run (agent busy) gateway behavior, driving the Guard-2 dispatcher in
|
|
# gateway/run.py (_dispatch_busy_slash_command):
|
|
# "dispatch" — run while busy (normal handler, or the
|
|
# mid-run variant named by ``busy_handler``).
|
|
# "reject" — refuse mid-run; generic "Agent is running"
|
|
# catch-all unless ``busy_handler`` names a
|
|
# command-specific reject message.
|
|
# "interrupt_then_dispatch" — interrupt the agent first (/stop, /new,
|
|
# /reset); Guard 1 (platforms/base.py) routes
|
|
# these via is_interrupt_then_dispatch().
|
|
busy_policy: str = "reject"
|
|
# Key of a special mid-run handler in gateway/run.py's Guard-2 table for
|
|
# commands whose busy behavior differs from their normal handler.
|
|
busy_handler: str | None = None
|
|
# Key in ``hermes_cli.slash_exec.EXECUTORS`` — a pure formatter producing
|
|
# the surface-independent core text; surfaces apply only their decoration.
|
|
# A string key (not a callable) keeps this module import-light for the
|
|
# gateway (no prompt_toolkit, no executor dependencies).
|
|
execute: str | None = None
|
|
# Desktop composer: ``options`` | ``text`` | ``mixed``. ``None`` is inferred.
|
|
argument_mode: str | None = None
|
|
# Desktop availability. ``None`` = offered; ``hidden`` = runs but stays out
|
|
# of the popover; otherwise a reason (terminal / messaging / settings / …).
|
|
desktop: str | None = None
|
|
|
|
|
|
# Valid values for CommandDef.busy_policy (see field docs above).
|
|
VALID_BUSY_POLICIES: frozenset[str] = frozenset(
|
|
{"dispatch", "reject", "interrupt_then_dispatch"}
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Central registry -- single source of truth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
COMMAND_REGISTRY: list[CommandDef] = [
|
|
# Session
|
|
CommandDef("start", "Acknowledge platform start pings without a reply", "Session",
|
|
gateway_only=True, busy_policy="dispatch", busy_handler="start"),
|
|
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
|
aliases=("reset",), args_hint="[name]",
|
|
busy_policy="interrupt_then_dispatch", busy_handler="new"),
|
|
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
|
|
gateway_only=True, args_hint="[off|help|session-id]"),
|
|
CommandDef("clear", "Clear screen and start a new session", "Session",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("history", "Show conversation history", "Session",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("save", "Export the current conversation (bare /save shows usage)", "Session",
|
|
args_hint="<json|md|html> [filename] [redact]"),
|
|
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
|
CommandDef("prompt", "Compose your next prompt in $EDITOR (markdown), then send it", "Session",
|
|
cli_only=True, args_hint="[initial text]", aliases=("compose",)),
|
|
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
|
|
args_hint="[N]"),
|
|
CommandDef("title", "Set a title for the current session", "Session",
|
|
args_hint="[name]"),
|
|
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
|
args_hint="<platform>", cli_only=True, argument_mode="options"),
|
|
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
|
aliases=("fork",), args_hint="[name]"),
|
|
CommandDef("worktree", "Show, list, create, or prune isolated git worktrees", "Session",
|
|
cli_only=True, args_hint="[new [name]|list|prune [--dry-run]]",
|
|
subcommands=("new", "list", "prune")),
|
|
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session",
|
|
aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"),
|
|
CommandDef("rollback", "List or restore filesystem checkpoints (restores keep your hand-edits; --all overrides)", "Session",
|
|
args_hint="[number] [--all]"),
|
|
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
|
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]",
|
|
desktop="terminal"),
|
|
CommandDef("export", "Export a profile (config, skills, theme) to a shareable archive", "Configuration",
|
|
cli_only=True, args_hint="[profile] [-o output.tar.gz]"),
|
|
CommandDef("import", "Import a shared profile archive as a new profile", "Configuration",
|
|
cli_only=True, args_hint="<archive.tar.gz> [--name <name>]"),
|
|
CommandDef("stop", "Kill all running background processes", "Session",
|
|
busy_policy="interrupt_then_dispatch", busy_handler="stop"),
|
|
CommandDef("pause", "Pause new work globally (emergency stop); '/pause off' resumes", "Session",
|
|
gateway_only=True, args_hint="[reason | off]",
|
|
busy_policy="dispatch"),
|
|
CommandDef("approve", "Approve a pending dangerous command", "Session",
|
|
gateway_only=True, args_hint="[session|always]", busy_policy="dispatch",
|
|
desktop="messaging"),
|
|
CommandDef("deny", "Deny a pending dangerous command (optionally with a reason)", "Session",
|
|
gateway_only=True, args_hint="[all] [reason]", busy_policy="dispatch",
|
|
desktop="messaging"),
|
|
CommandDef("bg", "Run a prompt in a separate background session", "Session",
|
|
args_hint="<prompt>", busy_policy="dispatch"),
|
|
CommandDef("btw", "Ask a side question about the current conversation without interrupting it", "Session",
|
|
args_hint="<question>", busy_policy="dispatch"),
|
|
CommandDef("agents", "Show active agents and running tasks", "Session",
|
|
aliases=("tasks",), busy_policy="dispatch"),
|
|
CommandDef("journey", "Open the learning journey timeline",
|
|
"Session", aliases=("learning", "memory-graph"), cli_only=True,
|
|
args_hint="[list|delete <id>|edit <id>]",
|
|
subcommands=("list", "delete", "edit")),
|
|
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
|
aliases=("q",), args_hint="<prompt>",
|
|
busy_policy="dispatch", busy_handler="queue"),
|
|
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
|
|
args_hint="<prompt>", busy_policy="dispatch", busy_handler="steer"),
|
|
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
|
|
args_hint="[text | draft <text> | show | gate add <cmd> | pause | resume | clear | status | wait <pid> | unwait]",
|
|
argument_mode="mixed", busy_policy="dispatch", busy_handler="goal"),
|
|
CommandDef("heartbeat", "Set a recurring prompt that re-enters this session when idle", "Session",
|
|
aliases=("hb",), args_hint="[every <interval> <prompt> | status | pause | resume | clear]",
|
|
subcommands=("status", "pause", "resume", "clear"),
|
|
busy_policy="dispatch"),
|
|
CommandDef("refine", "Review this conversation now and save lessons to memory/skills", "Session",
|
|
args_hint="[focus instructions]"),
|
|
CommandDef("review", "Spawn an independent subagent to review the work just discussed (PR, code, docs)", "Session",
|
|
args_hint="[review instructions]"),
|
|
CommandDef("loop", "Re-run a prompt on a recurring interval in this session", "Session",
|
|
aliases=("proactive",),
|
|
args_hint="[interval] <prompt> [--times N] [--until <condition>] | status | pause | resume | stop",
|
|
argument_mode="mixed", busy_policy="dispatch", busy_handler="loop"),
|
|
CommandDef("plan", "Write a markdown implementation plan to .hermes/plans/ without executing anything", "Session",
|
|
args_hint="[task]"),
|
|
CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session",
|
|
args_hint="<prompt>", busy_policy="reject", busy_handler="moa"),
|
|
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
|
args_hint="[text | remove N | clear]", busy_policy="dispatch"),
|
|
CommandDef("status", "Show session, model, token, and context info", "Session",
|
|
busy_policy="dispatch"),
|
|
CommandDef("egress", "Show Docker egress proxy status", "Session",
|
|
args_hint="[status]", subcommands=("status",),
|
|
busy_policy="dispatch", busy_handler="egress",
|
|
execute="egress"),
|
|
CommandDef("context", "Show detailed context window view with usage gauge, category breakdown, compression stats, and throughput", "Session",
|
|
aliases=("ctx",), args_hint="[all]", subcommands=("all",),
|
|
busy_policy="dispatch"),
|
|
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
|
CommandDef("profile", "Show active profile name and home directory", "Info",
|
|
busy_policy="dispatch", execute="profile"),
|
|
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
|
gateway_only=True, aliases=("set-home",), desktop="terminal"),
|
|
CommandDef("resume", "Resume a previously-named session", "Session",
|
|
args_hint="[name]", argument_mode="mixed"),
|
|
|
|
# Configuration
|
|
CommandDef("sessions", "Browse and resume previous sessions", "Session"),
|
|
|
|
# Configuration
|
|
CommandDef("config", "Show current configuration", "Configuration",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
|
|
args_hint="[model] [--provider name] [--global|--session] [--refresh]",
|
|
busy_policy="reject", busy_handler="model", desktop="hidden"),
|
|
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
|
"Configuration", aliases=("codex_runtime",),
|
|
args_hint="[auto|codex_app_server]",
|
|
busy_policy="reject", busy_handler="codex-runtime"),
|
|
|
|
CommandDef("personality", "Set a predefined personality", "Configuration",
|
|
args_hint="[name]", argument_mode="options"),
|
|
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
|
cli_only=True, aliases=("sb",), desktop="terminal"),
|
|
CommandDef("battery", "Toggle a color-coded battery indicator in the status bar",
|
|
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status")),
|
|
CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration",
|
|
cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status"), aliases=("ts",)),
|
|
CommandDef("diff", "Show git changes in the working directory", "Info",
|
|
args_hint="[staged|all|session] [--stat] [path...]",
|
|
subcommands=("staged", "all", "session")),
|
|
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
|
"Configuration", cli_only=True,
|
|
gateway_config_gate="display.tool_progress_command",
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("focus", "Toggle focus view — show only your prompt and the final response",
|
|
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status")),
|
|
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
|
|
"Configuration", args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status"), busy_policy="dispatch",
|
|
desktop="terminal"),
|
|
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
|
"Configuration", busy_policy="dispatch"),
|
|
CommandDef("approvals", "Show or set the persistent dangerous-command approval mode",
|
|
"Configuration", args_hint="[manual|smart|off]",
|
|
subcommands=("manual", "smart", "off")),
|
|
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
|
args_hint="[level|show|hide|full|clamp] [--global]",
|
|
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global"),
|
|
desktop="advanced"),
|
|
CommandDef("fast", "Fast mode — OpenAI Priority Processing / Anthropic Fast Mode (normal/fast/auto/cold)", "Configuration",
|
|
args_hint="[normal|fast|auto|cold|status] [--global]",
|
|
subcommands=("normal", "fast", "auto", "cold", "status", "on", "off", "--global"),
|
|
desktop="advanced"),
|
|
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
|
cli_only=True, args_hint="[name]", argument_mode="options"),
|
|
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
|
|
cli_only=True, args_hint=f"[{'|'.join(INDICATOR_STYLES)}]",
|
|
subcommands=INDICATOR_STYLES, desktop="terminal"),
|
|
CommandDef("voice", "Toggle voice mode", "Configuration",
|
|
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status"),
|
|
desktop="composer-voice"),
|
|
CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration",
|
|
cli_only=True, args_hint="[on|off|status]",
|
|
subcommands=("on", "off", "status")),
|
|
CommandDef("busy", "Control how messages behave while Hermes is working", "Configuration",
|
|
args_hint="[queue|steer|interrupt|status]",
|
|
subcommands=("queue", "steer", "interrupt", "status"),
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
|
|
# Tools & Skills
|
|
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
|
args_hint="[list|disable|enable] [name...]", cli_only=True,
|
|
argument_mode="options"),
|
|
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("skills", "Search, install, inspect, or manage skills",
|
|
"Tools & Skills", cli_only=True,
|
|
gateway_config_gate="skills.write_approval",
|
|
subcommands=("search", "browse", "inspect", "install", "audit",
|
|
"pending", "approve", "reject", "diff", "approval"),
|
|
desktop="settings"),
|
|
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
|
|
"Tools & Skills",
|
|
args_hint="[pending|approve|reject|approval] [id|on|off]",
|
|
subcommands=("pending", "approve", "reject", "approval")),
|
|
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
|
"Tools & Skills", execute="bundles"),
|
|
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
|
|
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
|
|
CommandDef("hatch", "Generate a new petdex pet from a description",
|
|
"Tools & Skills", cli_only=True, aliases=("generate-pet",), args_hint="[description]"),
|
|
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
|
|
"Tools & Skills", args_hint="<what to learn from>"),
|
|
CommandDef("init", "Generate or update AGENTS.md project instructions from a repo scan",
|
|
"Tools & Skills", args_hint="[notes]"),
|
|
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
|
cli_only=True, args_hint="[subcommand]",
|
|
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove"),
|
|
desktop="terminal"),
|
|
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
|
|
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
|
|
subcommands=("accept", "dismiss", "catalog", "clear")),
|
|
CommandDef("blueprint", "Set up an automation from a blueprint template",
|
|
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
|
|
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
|
|
"Tools & Skills", args_hint="[subcommand]",
|
|
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived"),
|
|
desktop="advanced"),
|
|
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
|
|
"Tools & Skills", args_hint="[subcommand]",
|
|
subcommands=("init", "boards", "create", "list", "ls", "show", "assign",
|
|
"reclaim", "reassign", "diagnostics", "diag", "link", "unlink",
|
|
"claim", "comment", "complete", "edit", "block", "unblock",
|
|
"archive", "tail", "dispatch", "stats", "notify-subscribe",
|
|
"notify-list", "notify-unsubscribe", "log", "runs",
|
|
"heartbeat", "assignees", "context", "specify", "gc"),
|
|
busy_policy="dispatch", desktop="advanced"),
|
|
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
|
aliases=("reload_mcp",), desktop="advanced"),
|
|
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
|
"Tools & Skills", aliases=("reload_skills",), desktop="advanced"),
|
|
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP, or switch to Browser Use mode", "Tools & Skills",
|
|
cli_only=True, args_hint="[connect|disconnect|status|use]",
|
|
subcommands=("connect", "disconnect", "status", "use")),
|
|
CommandDef("plugins", "List installed plugins and their status",
|
|
"Tools & Skills", cli_only=True, desktop="terminal"),
|
|
|
|
# Info
|
|
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
|
gateway_only=True, args_hint="[page]", busy_policy="dispatch",
|
|
execute="gateway_commands"),
|
|
CommandDef("help", "Show available commands (/help skills lists skill commands, /help <text> filters)", "Info", busy_policy="dispatch",
|
|
execute="gateway_help", args_hint="[skills|<filter>]"),
|
|
CommandDef("palette", "Open the fuzzy command palette (also Ctrl+P)", "Info",
|
|
cli_only=True, busy_policy="dispatch"),
|
|
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
|
|
gateway_only=True, busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
|
|
args_hint="[reset [--force]]"),
|
|
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
|
|
cli_only=True, aliases=("upgrade",)),
|
|
CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"),
|
|
CommandDef("insights", "Show usage insights and analytics", "Info",
|
|
args_hint="[days]", desktop="advanced"),
|
|
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
|
cli_only=True, aliases=("gateway",), desktop="terminal"),
|
|
CommandDef("platform", "Pause, resume, or list a failing gateway platform", "Info",
|
|
gateway_only=True, args_hint="<pause|resume|list> [name]"),
|
|
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
|
|
cli_only=True, args_hint="[number]", desktop="terminal"),
|
|
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
|
|
cli_only=True, desktop="terminal"),
|
|
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
|
cli_only=True, args_hint="<path>", desktop="terminal"),
|
|
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
|
|
busy_policy="dispatch", desktop="terminal"),
|
|
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",),
|
|
busy_policy="dispatch", execute="version"),
|
|
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info",
|
|
args_hint="[nous|local]"),
|
|
|
|
# Exit
|
|
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",
|
|
cli_only=True, aliases=("exit",), args_hint="[--delete]",
|
|
desktop="terminal"),
|
|
]
|
|
|
|
|
|
# Used only to distinguish ``mixed`` (subcommands plus free-text) from
|
|
# ``options`` (subcommand list only). A bare ``args_hint`` with no
|
|
# subcommands is always ``text`` — do not add tokens here for that path.
|
|
_PROSE_HINTS = ("<prompt>", "[text", "instructions", "[interval]", "<what")
|
|
|
|
|
|
def infer_argument_mode(cmd: CommandDef) -> str | None:
|
|
"""Composer mode: explicit on the CommandDef, else inferred from its args."""
|
|
if cmd.argument_mode in {"options", "text", "mixed"}:
|
|
return cmd.argument_mode
|
|
hint = (cmd.args_hint or "").strip()
|
|
if cmd.subcommands and hint and any(token in hint.lower() for token in _PROSE_HINTS):
|
|
return "mixed"
|
|
if cmd.subcommands:
|
|
return "options"
|
|
if hint:
|
|
return "text"
|
|
return None
|
|
|
|
|
|
def command_desktop_meta(cmd: CommandDef) -> dict[str, str | None]:
|
|
"""Wire shape for ``commands.catalog`` — reads the CommandDef, nothing else."""
|
|
return {"argument_mode": infer_argument_mode(cmd), "desktop": cmd.desktop}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Derived lookups -- rebuilt once at import time, refreshed by rebuild_lookups()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_command_lookup() -> dict[str, CommandDef]:
|
|
"""Map every name and alias to its CommandDef."""
|
|
lookup: dict[str, CommandDef] = {}
|
|
for cmd in COMMAND_REGISTRY:
|
|
lookup[cmd.name] = cmd
|
|
for alias in cmd.aliases:
|
|
lookup[alias] = cmd
|
|
return lookup
|
|
|
|
|
|
_COMMAND_LOOKUP: dict[str, CommandDef] = _build_command_lookup()
|
|
|
|
|
|
def resolve_command(name: str) -> CommandDef | None:
|
|
"""Resolve a command name or alias to its CommandDef.
|
|
|
|
Accepts names with or without the leading slash.
|
|
"""
|
|
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
|
|
|
|
|
|
def _build_description(cmd: CommandDef) -> str:
|
|
"""Build a CLI-facing description string including usage hint."""
|
|
if cmd.args_hint:
|
|
return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
|
|
return cmd.description
|
|
|
|
|
|
# Backwards-compatible flat dict ("/command" -> description) and the same
|
|
# grouped by category. Both exclude gateway_only commands.
|
|
COMMANDS: dict[str, str] = {}
|
|
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
|
|
# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
|
|
SUBCOMMANDS: dict[str, list[str]] = {}
|
|
for _cmd in COMMAND_REGISTRY:
|
|
if _cmd.subcommands:
|
|
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
|
|
if _cmd.gateway_only:
|
|
continue
|
|
_entries = {f"/{_cmd.name}": _build_description(_cmd)}
|
|
for _alias in _cmd.aliases:
|
|
_entries[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
|
|
COMMANDS.update(_entries)
|
|
COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {}).update(_entries)
|
|
|
|
|
|
# /help renderer sub-groups for the ~46-command "Session" category. Category
|
|
# itself is load-bearing for gateway help, so commands are not re-tagged;
|
|
# unlisted Session commands fall under the base header. Bare names (no /).
|
|
HELP_SESSION_SUBGROUPS: dict[str, tuple[str, ...]] = {
|
|
"Context": (
|
|
"compress", "compact", "context", "ctx", "status",
|
|
),
|
|
"Background & Automation": (
|
|
"bg", "btw", "agents", "tasks", "queue", "q", "steer",
|
|
"goal", "subgoal", "heartbeat", "hb", "refine", "loop", "proactive",
|
|
"moa", "journey", "learning", "memory-graph",
|
|
),
|
|
}
|
|
|
|
# Fallback: derive subcommands from pipe patterns in args_hint ("[on|off|status]")
|
|
# for commands without an explicit ``subcommands`` field.
|
|
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
|
|
for _cmd in COMMAND_REGISTRY:
|
|
key = f"/{_cmd.name}"
|
|
if key in SUBCOMMANDS or not _cmd.args_hint:
|
|
continue
|
|
m = _PIPE_SUBS_RE.search(_cmd.args_hint)
|
|
if m:
|
|
SUBCOMMANDS[key] = m.group(0).split("|")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gateway helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# All names + aliases the gateway dispatches. Config-gated commands are
|
|
# included; their handler checks the gate at runtime.
|
|
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
|
name
|
|
for cmd in COMMAND_REGISTRY
|
|
if not cmd.cli_only or cmd.gateway_config_gate
|
|
for name in (cmd.name, *cmd.aliases)
|
|
)
|
|
|
|
|
|
def is_gateway_known_command(name: str | None) -> bool:
|
|
"""Return True if ``name`` is a built-in or plugin gateway slash command.
|
|
|
|
Plugin commands are looked up lazily so importing this module never forces
|
|
plugin discovery. Gateway code uses this to decide whether to emit
|
|
``command:<name>`` hooks — plugins get the same lifecycle events as built-ins.
|
|
"""
|
|
if not name:
|
|
return False
|
|
return name in GATEWAY_KNOWN_COMMANDS or any(
|
|
plugin_name == name for plugin_name, _d, _h in _iter_plugin_command_entries()
|
|
)
|
|
|
|
|
|
# Commands with explicit mid-run handling (busy_policy != "reject"). Kept
|
|
# under its historical name for introspection/tests; the real bypass set is
|
|
# every resolvable command (see should_bypass_active_session).
|
|
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
|
cmd.name for cmd in COMMAND_REGISTRY if cmd.busy_policy != "reject"
|
|
)
|
|
|
|
|
|
def is_interrupt_then_dispatch(command_name: str | None) -> bool:
|
|
"""True when *command_name* (or alias) has busy_policy "interrupt_then_dispatch".
|
|
|
|
Guard 1 (gateway/platforms/base.py) routes these through the cancel-handoff
|
|
path that serializes cancellation + runner response + pending drain.
|
|
"""
|
|
if not command_name:
|
|
return False
|
|
cmd = resolve_command(command_name)
|
|
return cmd is not None and cmd.busy_policy == "interrupt_then_dispatch"
|
|
|
|
|
|
def should_bypass_active_session(command_name: str | None) -> bool:
|
|
"""Return True for any resolvable slash command.
|
|
|
|
Every recognized slash command is dispatched mid-run — either by its
|
|
Level-2 handler in gateway/run.py or by the "busy — wait or /stop first"
|
|
catch-all — never queued: gateway.run's safety net discards command text
|
|
that reaches the pending queue, so a queued mid-run /model would interrupt
|
|
the agent AND vanish with a zero-char response.
|
|
ACTIVE_SESSION_BYPASS_COMMANDS is the subset with explicit handlers.
|
|
"""
|
|
return resolve_command(command_name) is not None if command_name else False
|
|
|
|
|
|
def _resolve_config_gates() -> set[str]:
|
|
"""Return canonical names of commands whose ``gateway_config_gate`` dotpath is truthy in config.yaml (empty set on any error)."""
|
|
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
|
|
if not gated:
|
|
return set()
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
cfg = read_raw_config()
|
|
except Exception:
|
|
return set()
|
|
result: set[str] = set()
|
|
for cmd in gated:
|
|
val: Any = cfg
|
|
for key in cmd.gateway_config_gate.split("."):
|
|
if isinstance(val, dict):
|
|
val = val.get(key)
|
|
else:
|
|
val = None
|
|
break
|
|
if is_truthy_value(val, default=False):
|
|
result.add(cmd.name)
|
|
return result
|
|
|
|
|
|
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
|
|
"""True if *cmd* appears in gateway surfaces: not ``cli_only``, or its config gate is truthy.
|
|
|
|
Pass *config_overrides* from ``_resolve_config_gates()`` to avoid
|
|
re-reading config per command.
|
|
"""
|
|
if not cmd.cli_only:
|
|
return True
|
|
if cmd.gateway_config_gate:
|
|
overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
|
|
return cmd.name in overrides
|
|
return False
|
|
|
|
|
|
def _requires_argument(args_hint: str) -> bool:
|
|
"""Return True when selecting a command without text would be incomplete."""
|
|
return args_hint.strip().startswith("<")
|
|
|
|
|
|
def gateway_help_lines() -> list[str]:
|
|
"""Generate gateway help text lines from the registry."""
|
|
overrides = _resolve_config_gates()
|
|
lines: list[str] = []
|
|
for cmd in COMMAND_REGISTRY:
|
|
if not _is_gateway_available(cmd, overrides):
|
|
continue
|
|
args = f" {cmd.args_hint}" if cmd.args_hint else ""
|
|
alias_parts: list[str] = []
|
|
for a in cmd.aliases:
|
|
# Skip internal aliases like reload_mcp (underscore variant)
|
|
if a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name:
|
|
continue
|
|
alias_parts.append(f"`/{a}`")
|
|
alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
|
|
lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
|
|
return lines
|
|
|
|
|
|
def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
|
"""Return (name, description, args_hint) tuples for all plugin slash commands.
|
|
|
|
Registered via :func:`hermes_cli.plugins.PluginContext.register_command`;
|
|
surfaced like ``CommandDef`` entries (Telegram menu, Slack ``/hermes``
|
|
map, Discord picker). Lookup is lazy so importing this module never
|
|
forces plugin discovery (filesystem scans, env-dependent behavior).
|
|
"""
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_commands
|
|
except Exception:
|
|
return []
|
|
try:
|
|
commands = get_plugin_commands() or {}
|
|
except Exception:
|
|
return []
|
|
entries: list[tuple[str, str, str]] = []
|
|
for name, meta in commands.items():
|
|
if not isinstance(name, str) or not isinstance(meta, dict):
|
|
continue
|
|
description = str(meta.get("description") or f"Run /{name}")
|
|
args_hint = str(meta.get("args_hint") or "").strip()
|
|
entries.append((name, description, args_hint))
|
|
return entries
|
|
|
|
|
|
def telegram_bot_commands(*, include_plugins: bool = True) -> list[tuple[str, str]]:
|
|
"""Return (command_name, description) pairs for Telegram setMyCommands.
|
|
|
|
Names are Telegram-sanitized (hyphens → underscores); aliases are skipped
|
|
(one menu entry per canonical command). Built-ins that require arguments
|
|
are **included** — their handlers show usage text when selected bare, so
|
|
hiding them hurts discoverability. Plugin commands requiring arguments are
|
|
**excluded** because plugins may lack a no-arg fallback; callers needing
|
|
source metadata pass ``include_plugins=False`` and use
|
|
:func:`_collect_gateway_skill_entries`.
|
|
"""
|
|
overrides = _resolve_config_gates()
|
|
result: list[tuple[str, str]] = []
|
|
for cmd in COMMAND_REGISTRY:
|
|
if not _is_gateway_available(cmd, overrides):
|
|
continue
|
|
tg_name = _sanitize_telegram_name(cmd.name)
|
|
if tg_name:
|
|
result.append((tg_name, cmd.description))
|
|
if include_plugins:
|
|
for name, description, args_hint in _iter_plugin_command_entries():
|
|
if _requires_argument(args_hint):
|
|
continue
|
|
tg_name = _sanitize_telegram_name(name)
|
|
if tg_name:
|
|
result.append((tg_name, description))
|
|
return result
|
|
|
|
|
|
# Telegram allows 100 BotCommands; the 60-slot default keeps every built-in
|
|
# plus common skill commands visible while staying under the ~4KB payload
|
|
# limit. Tunable via platforms.telegram.extra.command_menu.max_commands.
|
|
_DEFAULT_TELEGRAM_MENU_MAX_COMMANDS = 60
|
|
_TELEGRAM_BOT_API_MAX_COMMANDS = 100
|
|
# priority_mode -> rank tables consulted in order ("configured" = user list,
|
|
# "default" = _TELEGRAM_MENU_PRIORITY); unranked candidates keep stable order after.
|
|
_TELEGRAM_PRIORITY_TIERS: dict[str, tuple[str, ...]] = {
|
|
"prepend": ("configured", "default"),
|
|
"append": ("default", "configured"),
|
|
"replace": ("configured",),
|
|
}
|
|
_TELEGRAM_PRIORITY_MODES = frozenset(_TELEGRAM_PRIORITY_TIERS)
|
|
|
|
_TELEGRAM_MENU_PRIORITY = (
|
|
# Most-typed everyday commands first.
|
|
"help",
|
|
"new",
|
|
"stop",
|
|
"status",
|
|
"egress",
|
|
"resume",
|
|
"sessions",
|
|
"model",
|
|
# Maintenance / diagnostics — the ones that prompted this priority list.
|
|
"debug",
|
|
"restart",
|
|
"update",
|
|
"verbose",
|
|
"commands",
|
|
# Mid-turn session control.
|
|
"approve",
|
|
"deny",
|
|
"queue",
|
|
"steer",
|
|
"bg",
|
|
"btw",
|
|
# Lower-priority but still useful operational built-ins.
|
|
"reasoning",
|
|
"usage",
|
|
"platforms",
|
|
"platform",
|
|
"profile",
|
|
"whoami",
|
|
)
|
|
"""Built-ins that must survive Telegram's small visible menu cap; everything
|
|
else stays dispatchable when typed manually."""
|
|
|
|
|
|
def _nested_mapping(root: Mapping[str, Any], *path: str) -> Mapping[str, Any]:
|
|
node: Any = root
|
|
for key in path:
|
|
if not isinstance(node, Mapping):
|
|
return {}
|
|
node = node.get(key)
|
|
return node if isinstance(node, Mapping) else {}
|
|
|
|
|
|
def _telegram_command_menu_config() -> dict[str, Any]:
|
|
"""Return normalized Telegram command-menu config with safe defaults.
|
|
|
|
Canonical user-facing path:
|
|
``platforms.telegram.extra.command_menu``.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
raw_cfg = read_raw_config() or {}
|
|
except Exception:
|
|
raw_cfg = {}
|
|
if not isinstance(raw_cfg, Mapping):
|
|
raw_cfg = {}
|
|
|
|
menu_cfg = dict(_nested_mapping(raw_cfg, "platforms", "telegram", "extra", "command_menu"))
|
|
|
|
max_commands = menu_cfg.get("max_commands", _DEFAULT_TELEGRAM_MENU_MAX_COMMANDS)
|
|
try:
|
|
max_commands = int(max_commands)
|
|
except (TypeError, ValueError):
|
|
max_commands = _DEFAULT_TELEGRAM_MENU_MAX_COMMANDS
|
|
max_commands = max(1, min(_TELEGRAM_BOT_API_MAX_COMMANDS, max_commands))
|
|
|
|
priority_mode = str(menu_cfg.get("priority_mode") or "prepend").strip().lower()
|
|
if priority_mode not in _TELEGRAM_PRIORITY_MODES:
|
|
priority_mode = "prepend"
|
|
|
|
raw_priority = menu_cfg.get("priority")
|
|
if isinstance(raw_priority, list):
|
|
priority = [str(item) for item in raw_priority if str(item).strip()]
|
|
elif isinstance(raw_priority, str) and raw_priority.strip():
|
|
priority = [raw_priority]
|
|
else:
|
|
priority = []
|
|
|
|
return {
|
|
"max_commands": max_commands,
|
|
"priority_mode": priority_mode,
|
|
"priority": priority,
|
|
}
|
|
|
|
|
|
def telegram_menu_max_commands() -> int:
|
|
"""Return configured Telegram BotCommand menu cap with safe bounds."""
|
|
return int(_telegram_command_menu_config()["max_commands"])
|
|
|
|
|
|
def _dedupe_sanitized_names(raw_names: list[str] | tuple[str, ...]) -> tuple[str, ...]:
|
|
result: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw_name in raw_names:
|
|
name = _sanitize_telegram_name(str(raw_name))
|
|
if name and name not in seen:
|
|
seen.add(name)
|
|
result.append(name)
|
|
return tuple(result)
|
|
|
|
|
|
def _prioritize_telegram_menu_candidates(
|
|
candidates: list[tuple[str, str, str, str]],
|
|
) -> list[tuple[str, str, str, str]]:
|
|
"""Order Telegram candidates while keeping default priority core-only.
|
|
|
|
Candidate tuples contain ``(final_name, description, source, raw_name)``.
|
|
``raw_name`` preserves the pre-clamp command name so an explicitly
|
|
configured long command remains addressable after Telegram name clamping.
|
|
"""
|
|
menu_cfg = _telegram_command_menu_config()
|
|
configured_rank = {n: i for i, n in enumerate(_dedupe_sanitized_names(menu_cfg["priority"]))}
|
|
default_rank = {n: i for i, n in enumerate(_dedupe_sanitized_names(_TELEGRAM_MENU_PRIORITY))}
|
|
# Tier order per mode: which rank tables win, in precedence order.
|
|
# "replace" ignores the built-in defaults entirely.
|
|
tiers = _TELEGRAM_PRIORITY_TIERS[menu_cfg["priority_mode"]]
|
|
|
|
def _rank(stable_index: int, candidate: tuple[str, str, str, str]) -> tuple[int, int, int]:
|
|
final_name, _desc, source, raw_name = candidate
|
|
indexes = {
|
|
"configured": configured_rank.get(raw_name, configured_rank.get(final_name)),
|
|
"default": default_rank.get(final_name) if source == "core" else None,
|
|
}
|
|
for tier, table in enumerate(tiers):
|
|
if indexes[table] is not None:
|
|
return (tier, indexes[table], stable_index)
|
|
return (len(tiers), 0, stable_index)
|
|
|
|
return [c for _i, c in sorted(enumerate(candidates), key=lambda item: _rank(*item))]
|
|
|
|
|
|
_CMD_NAME_LIMIT = 32
|
|
"""Max command name length shared by Telegram and Discord."""
|
|
|
|
_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]")
|
|
_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}")
|
|
|
|
|
|
def _sanitize_telegram_name(raw: str) -> str:
|
|
"""Convert a command/skill/plugin name to a valid Telegram command name.
|
|
|
|
Telegram allows only lowercase a-z, 0-9 and underscores: lowercase →
|
|
hyphens to underscores → strip other chars → collapse/strip underscores.
|
|
"""
|
|
name = raw.lower().replace("-", "_")
|
|
name = _TG_INVALID_CHARS.sub("", name)
|
|
name = _TG_MULTI_UNDERSCORE.sub("_", name)
|
|
return name.strip("_")
|
|
|
|
|
|
def _clamp_command_names(
|
|
entries: Sequence[tuple[str, ...]],
|
|
reserved: set[str],
|
|
) -> list[tuple[str, ...]]:
|
|
"""Enforce the 32-char Telegram/Discord command-name limit with collision avoidance.
|
|
|
|
Over-long names are truncated; if that collides with *reserved* or an
|
|
earlier entry, a 31-char prefix + digit ``0``-``9`` is tried, and the entry
|
|
is silently dropped when all ten are taken. Duplicate names are dropped.
|
|
Elements beyond ``(name, desc)`` pass through unchanged.
|
|
"""
|
|
used: set[str] = set(reserved)
|
|
result: list = []
|
|
for entry in entries:
|
|
name, desc, *extra = entry
|
|
if len(name) > _CMD_NAME_LIMIT:
|
|
candidate = name[:_CMD_NAME_LIMIT]
|
|
if candidate in used:
|
|
prefix = name[:_CMD_NAME_LIMIT - 1]
|
|
for digit in range(10):
|
|
candidate = f"{prefix}{digit}"
|
|
if candidate not in used:
|
|
break
|
|
else:
|
|
continue
|
|
name = candidate
|
|
if name in used:
|
|
continue
|
|
used.add(name)
|
|
result.append((name, desc, *extra))
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared skill/plugin collection for gateway platforms
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _truncate_desc(desc: str, limit: int) -> str:
|
|
"""Clamp a menu description to *limit* chars with a ``...`` tail."""
|
|
return desc if len(desc) <= limit else desc[:limit - 3] + "..."
|
|
|
|
|
|
def _iter_gateway_skills(platform: str):
|
|
"""Yield ``(cmd_key, info, rel_parts)`` for skills eligible as gateway slash commands.
|
|
|
|
Scan roots are the local ``SKILLS_DIR`` plus every configured
|
|
``skills.external_dirs`` / trusted project skills dir (#8110, #18741) —
|
|
a skill anywhere else, or under the hub dir (``SKILLS_DIR/.hub``), is
|
|
skipped, as are skills disabled for *platform*. Paths are resolved on both
|
|
sides so symlinked roots (macOS ``/var`` → ``/private/var``) still match,
|
|
and matching is per path component so ``/my-skills`` never admits
|
|
``/my-skills-extra``. ``rel_parts`` is the skill dir relative to its root
|
|
(``("creative", "ascii-art")``) for category derivation. Iterates
|
|
``sorted(skill_cmds)`` so first-wins collision handling is alphabetical.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from agent.skill_commands import get_skill_commands
|
|
from agent.skill_utils import get_disabled_skill_names, get_external_skills_dirs, get_project_skills_dirs
|
|
from tools.skills_tool import SKILLS_DIR
|
|
|
|
try:
|
|
disabled = get_disabled_skill_names(platform=platform)
|
|
except Exception:
|
|
disabled = set()
|
|
hub_dir = (SKILLS_DIR / ".hub").resolve()
|
|
roots = [SKILLS_DIR.resolve()]
|
|
for getter in (get_external_skills_dirs, get_project_skills_dirs):
|
|
try:
|
|
for d in getter():
|
|
try:
|
|
roots.append(Path(d).resolve())
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
skill_cmds = get_skill_commands()
|
|
for cmd_key in sorted(skill_cmds):
|
|
info = skill_cmds[cmd_key]
|
|
skill_path = info.get("skill_md_path", "")
|
|
if not skill_path:
|
|
continue
|
|
sp = Path(skill_path).resolve()
|
|
if sp.is_relative_to(hub_dir):
|
|
continue
|
|
root = next((r for r in roots if sp.is_relative_to(r)), None)
|
|
if root is None or info.get("name", "") in disabled:
|
|
continue
|
|
yield cmd_key, info, sp.parent.relative_to(root).parts
|
|
|
|
|
|
def _collect_gateway_skill_entries(
|
|
platform: str,
|
|
max_slots: int | None,
|
|
reserved_names: set[str],
|
|
desc_limit: int = 100,
|
|
sanitize_name: "Callable[[str], str] | None" = None,
|
|
) -> tuple[list[tuple[str, str, str, str]], int]:
|
|
"""Collect plugin + skill entries for a gateway platform.
|
|
|
|
Plugin slash commands come first and are never trimmed; skill commands
|
|
(alphabetical, see :func:`_iter_gateway_skills`) fill the remaining
|
|
*max_slots* — ``None`` returns every candidate for a caller applying its
|
|
own global cap. *reserved_names* (built-in names) is mutated in place as
|
|
names are claimed. *sanitize_name* runs before clamping and may return ""
|
|
to skip an entry. Returns ``(entries, hidden_count)`` with entries of
|
|
``(name, description, cmd_key, raw_name)`` — ``cmd_key`` is the original
|
|
skill key ("" for plugins); ``raw_name`` the sanitized pre-clamp name used
|
|
for configured-priority matching.
|
|
"""
|
|
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
|
|
# Plugins have no cmd_key — "" placeholder; raw_name is the sanitized pre-clamp name.
|
|
plugin_entries: list[tuple[str, str, str, str]] = []
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_commands
|
|
plugin_cmds = get_plugin_commands()
|
|
for cmd_name in sorted(plugin_cmds):
|
|
if platform == "telegram":
|
|
args_hint = str(plugin_cmds[cmd_name].get("args_hint") or "").strip()
|
|
if _requires_argument(args_hint):
|
|
continue
|
|
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
|
if not name:
|
|
continue
|
|
desc = _truncate_desc(plugin_cmds[cmd_name].get("description", "Plugin command"), desc_limit)
|
|
plugin_entries.append((name, desc, "", name))
|
|
except Exception:
|
|
pass
|
|
plugin_entries = _clamp_command_names(plugin_entries, reserved_names)
|
|
reserved_names.update(n for n, *_rest in plugin_entries)
|
|
|
|
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
|
|
skill_entries: list[tuple[str, str, str, str]] = []
|
|
try:
|
|
for cmd_key, info, _rel in _iter_gateway_skills(platform):
|
|
raw_name = cmd_key.lstrip("/")
|
|
name = sanitize_name(raw_name) if sanitize_name else raw_name
|
|
if not name:
|
|
continue
|
|
skill_entries.append((name, _truncate_desc(info.get("description", ""), desc_limit), cmd_key, name))
|
|
except Exception:
|
|
pass
|
|
# Clamp names; cmd_key and raw_name survive any clamp-induced rename.
|
|
skill_entries = _clamp_command_names(skill_entries, reserved_names)
|
|
|
|
if max_slots is None:
|
|
return plugin_entries + skill_entries, 0
|
|
|
|
# Skills fill remaining slots — only tier that gets trimmed
|
|
remaining = max(0, max_slots - len(plugin_entries))
|
|
hidden_count = max(0, len(skill_entries) - remaining)
|
|
return (plugin_entries + skill_entries[:remaining])[:max_slots], hidden_count
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Platform-specific wrappers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
|
"""Return ``(menu_commands, hidden_count)`` for Telegram, capped to the Bot API limit.
|
|
|
|
Tier order: core CommandDefs, then plugin slash commands, then skill
|
|
commands (alphabetical; hub skills and telegram-disabled skills excluded).
|
|
Tiers keep their relative order unless named in
|
|
``platforms.telegram.extra.command_menu.priority`` — explicit priority is
|
|
applied to the combined list *before* the cap, so a prioritized dynamic
|
|
command can displace an unprioritized core command.
|
|
"""
|
|
core_commands = list(telegram_bot_commands(include_plugins=False))
|
|
reserved_names = {n for n, _ in core_commands}
|
|
entries, hidden_count = _collect_gateway_skill_entries(
|
|
platform="telegram",
|
|
max_slots=None,
|
|
reserved_names=reserved_names,
|
|
desc_limit=40,
|
|
sanitize_name=_sanitize_telegram_name,
|
|
)
|
|
candidates = [(name, desc, "core", name) for name, desc in core_commands]
|
|
for name, desc, cmd_key, raw_name in entries:
|
|
source = "skill" if cmd_key else "plugin"
|
|
candidates.append((name, desc, source, raw_name))
|
|
|
|
candidates = _prioritize_telegram_menu_candidates(candidates)
|
|
overflow_count = max(0, len(candidates) - max_commands)
|
|
menu = [(name, desc) for name, desc, _source, _raw_name in candidates[:max_commands]]
|
|
return menu, hidden_count + overflow_count
|
|
|
|
|
|
def discord_skill_commands_by_category(
|
|
reserved_names: set[str],
|
|
) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]:
|
|
"""Return ``(categories, uncategorized, hidden_count)`` for Discord ``/skill`` autocomplete.
|
|
|
|
Skills nested >= 2 levels under a scan root (``creative/ascii-art/SKILL.md``)
|
|
are grouped under ``categories[top_level]``; root-level skills are
|
|
*uncategorized*. Entries are ``(name, description, cmd_key)`` with names
|
|
clamped to 32 chars and descriptions to 100. Eligibility follows
|
|
:func:`_iter_gateway_skills`. No per-group cap is applied — the caller
|
|
flattens everything into one autocomplete callback, which scales to
|
|
thousands of entries; ``hidden_count`` only reports 32-char clamp
|
|
collisions against reserved names or earlier skills.
|
|
"""
|
|
categories: dict[str, list[tuple[str, str, str]]] = {}
|
|
uncategorized: list[tuple[str, str, str]] = []
|
|
# clamped-32-char-name → origin, so collisions get an actionable warning.
|
|
# Reserved (gateway-builtin) names carry a sentinel so the warning can
|
|
# distinguish "collided with a reserved command" from "two skills collided
|
|
# on the 32-char clamp" — the latter is the rename-worthy case.
|
|
_names_used: dict[str, str] = dict.fromkeys(reserved_names, "<reserved>")
|
|
hidden = 0
|
|
|
|
try:
|
|
for cmd_key, info, rel_parts in _iter_gateway_skills("discord"):
|
|
# Clamp to 32 chars (Discord per-command name limit). On collision
|
|
# the first (alphabetical) skill wins and the loser is dropped from
|
|
# the picker; warn loudly, since a silent ``hidden`` count gave
|
|
# skill authors no way to discover the drop.
|
|
discord_name = cmd_key.lstrip("/")[:32]
|
|
if discord_name in _names_used:
|
|
prior = _names_used[discord_name]
|
|
if prior == "<reserved>":
|
|
logger.warning(
|
|
"Discord /skill: %r (from %r) collides on its 32-char "
|
|
"clamp with a reserved gateway command name %r — the "
|
|
"skill will not appear in the /skill autocomplete. "
|
|
"Rename the skill's frontmatter ``name:`` to differ "
|
|
"in its first 32 chars.",
|
|
discord_name, cmd_key, discord_name,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Discord /skill: %r and %r both clamp to %r on "
|
|
"Discord's 32-char command-name limit — only %r "
|
|
"will appear in the /skill autocomplete. Rename "
|
|
"one skill's frontmatter ``name:`` to differ in "
|
|
"its first 32 chars.",
|
|
prior, cmd_key, discord_name, prior,
|
|
)
|
|
hidden += 1
|
|
continue
|
|
_names_used[discord_name] = cmd_key
|
|
entry = (discord_name, _truncate_desc(info.get("description", ""), 100), cmd_key)
|
|
# creative/ascii-art/SKILL.md → category "creative"; root-level skills are uncategorized.
|
|
if len(rel_parts) >= 2:
|
|
categories.setdefault(rel_parts[0], []).append(entry)
|
|
else:
|
|
uncategorized.append(entry)
|
|
except Exception:
|
|
pass
|
|
|
|
return categories, uncategorized, hidden
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slack native slash commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Slack slash command name constraints: lowercase a-z, 0-9, hyphens,
|
|
# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash
|
|
# commands per app.
|
|
_SLACK_MAX_SLASH_COMMANDS = 50
|
|
_SLACK_NAME_LIMIT = 32
|
|
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
|
|
_SLACK_RESERVED_COMMANDS = frozenset({
|
|
# Built-in Slack slash commands that cannot be registered by apps.
|
|
# https://slack.com/help/articles/201259356-Use-built-in-slash-commands
|
|
"me", "status", "away", "dnd", "shrug", "remind", "msg", "feed",
|
|
"who", "collapse", "expand", "leave", "join", "open", "search",
|
|
"topic", "mute", "pro", "shortcuts",
|
|
})
|
|
|
|
# Canonical commands intentionally NOT given a native Slack slash slot. Slack
|
|
# caps apps at 50 slash commands and the registry is at that ceiling; rather
|
|
# than let the clamp silently drop whichever command sorts last (breaking the
|
|
# Telegram-parity test), low-frequency commands are routed through
|
|
# ``/hermes <command>`` on Slack only. They stay native on every other surface.
|
|
# Rule: when a new canonical command tips the registry past the cap, demote a
|
|
# rarer one-off lookup here (version, whoami, platform, diff, update, ...)
|
|
# rather than a recurring interactive surface (context, loop, save, approvals).
|
|
# Keep TIGHT and intentional — the parity test reads this set. (Aliases are
|
|
# never pinned ahead of canonicals: /bg and /btw became canonical commands
|
|
# instead, so they win first-pass slots on their own.)
|
|
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights"})
|
|
|
|
|
|
def _sanitize_slack_name(raw: str) -> str:
|
|
"""Convert a command name to a valid Slack slash command name.
|
|
|
|
Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32
|
|
chars. Uppercase is lowercased; invalid chars are stripped.
|
|
"""
|
|
name = raw.lower()
|
|
name = _SLACK_INVALID_CHARS.sub("", name)
|
|
name = name.strip("-_")
|
|
return name[:_SLACK_NAME_LIMIT]
|
|
|
|
|
|
def slack_native_slashes() -> list[tuple[str, str, str]]:
|
|
"""Return (slash_name, description, usage_hint) triples for Slack.
|
|
|
|
Every gateway-available command (canonical names first, then aliases,
|
|
then plugin commands) becomes a standalone Slack slash, clamped to the
|
|
50-command cap with duplicate avoidance. Names colliding with a Slack
|
|
built-in (``/status``, ``/me``, ...) or listed in _SLACK_VIA_HERMES_ONLY
|
|
are skipped. ``/hermes`` is always the first entry so the
|
|
``/hermes <command>`` form keeps working for anything dropped.
|
|
"""
|
|
overrides = _resolve_config_gates()
|
|
entries: list[tuple[str, str, str]] = []
|
|
seen: set[str] = set()
|
|
|
|
# Reserve /hermes as the catch-all top-level command.
|
|
entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]"))
|
|
seen.add("hermes")
|
|
|
|
def _add(name: str, desc: str, hint: str) -> None:
|
|
slack_name = _sanitize_slack_name(name)
|
|
if (
|
|
not slack_name
|
|
or slack_name in seen
|
|
or slack_name in _SLACK_RESERVED_COMMANDS
|
|
or slack_name in _SLACK_VIA_HERMES_ONLY
|
|
or len(entries) >= _SLACK_MAX_SLASH_COMMANDS
|
|
):
|
|
return
|
|
# Slack description cap is 2000 chars; keep it short.
|
|
entries.append((slack_name, desc[:140], hint[:100]))
|
|
seen.add(slack_name)
|
|
|
|
available = [cmd for cmd in COMMAND_REGISTRY if _is_gateway_available(cmd, overrides)]
|
|
# Canonical names first so they win slots at the cap; aliases second.
|
|
for cmd in available:
|
|
_add(cmd.name, cmd.description, cmd.args_hint or "")
|
|
for cmd in available:
|
|
for alias in cmd.aliases:
|
|
_add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
|
|
|
# Third pass: plugin commands.
|
|
for name, description, args_hint in _iter_plugin_command_entries():
|
|
_add(name, description, args_hint or "")
|
|
|
|
return entries
|
|
|
|
|
|
def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]:
|
|
"""Return the ``features.slash_commands`` manifest portion for all gateway slashes.
|
|
|
|
``request_url`` is schema-required but ignored in Socket Mode (a
|
|
placeholder is fine). Only this portion is returned so we stay decoupled
|
|
from the rest of the manifest users configure once in the Slack UI.
|
|
"""
|
|
slashes = []
|
|
for name, desc, usage in slack_native_slashes():
|
|
entry = {
|
|
"command": f"/{name}",
|
|
"description": desc or f"Run /{name}",
|
|
"should_escape": False,
|
|
"url": request_url,
|
|
}
|
|
if usage:
|
|
entry["usage_hint"] = usage
|
|
slashes.append(entry)
|
|
return {"features": {"slash_commands": slashes}}
|
|
|
|
|
|
def slack_subcommand_map() -> dict[str, str]:
|
|
"""Return name/alias -> "/command" mapping for the Slack ``/hermes`` handler, plugin commands included."""
|
|
overrides = _resolve_config_gates()
|
|
mapping: dict[str, str] = {}
|
|
for cmd in COMMAND_REGISTRY:
|
|
if not _is_gateway_available(cmd, overrides):
|
|
continue
|
|
mapping[cmd.name] = f"/{cmd.name}"
|
|
for alias in cmd.aliases:
|
|
mapping[alias] = f"/{alias}"
|
|
for name, _description, _args_hint in _iter_plugin_command_entries():
|
|
if name not in mapping:
|
|
mapping[name] = f"/{name}"
|
|
return mapping
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Autocomplete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SlashCommandCompleter(Completer):
|
|
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
|
|
|
def __init__(
|
|
self,
|
|
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
|
command_filter: Callable[[str], bool] | None = None,
|
|
skill_bundles_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
|
) -> None:
|
|
self._skill_commands_provider = skill_commands_provider
|
|
self._command_filter = command_filter
|
|
self._skill_bundles_provider = skill_bundles_provider
|
|
# Cached project file list for fuzzy @ completions
|
|
self._file_cache: list[str] = []
|
|
self._file_cache_time: float = 0.0
|
|
self._file_cache_cwd: str = ""
|
|
|
|
def _command_allowed(self, slash_command: str) -> bool:
|
|
if self._command_filter is None:
|
|
return True
|
|
try:
|
|
return bool(self._command_filter(slash_command))
|
|
except Exception:
|
|
return True
|
|
|
|
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 {}
|
|
|
|
def _iter_skill_bundles(self) -> Mapping[str, dict[str, Any]]:
|
|
if self._skill_bundles_provider is None:
|
|
return {}
|
|
try:
|
|
return self._skill_bundles_provider() or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
# -- stacked slash-skill completion helpers ---------------------------
|
|
|
|
@staticmethod
|
|
def _normalize_skill_token(token: str) -> str:
|
|
"""Canonicalize a typed skill token to its hyphenated /slug form.
|
|
|
|
Mirrors resolve_skill_command_key() in agent/skill_commands.py:
|
|
underscores (Telegram bot-command form) are interchangeable with
|
|
hyphens.
|
|
"""
|
|
return "/" + token.lstrip("/").replace("_", "-").lower()
|
|
|
|
def _is_skill_command(self, token: str) -> bool:
|
|
return self._normalize_skill_token(token) in self._iter_skill_commands()
|
|
|
|
def _stacked_skill_completions(self, text: str):
|
|
"""Offer skill-command completions for stacked invocations (``/skill-a /skill-b do XYZ``).
|
|
|
|
Keep suggesting while every completed token is a distinct skill
|
|
command, the cap is not reached, and the current word starts with
|
|
``/``; once the chain breaks, offer nothing — instruction text must
|
|
never be polluted with skill suggestions.
|
|
"""
|
|
try:
|
|
from agent.skill_commands import _MAX_STACKED_SKILLS as _cap
|
|
except Exception:
|
|
_cap = 5
|
|
|
|
tokens = text.split()
|
|
if text.endswith(" "):
|
|
completed, current_word = tokens, ""
|
|
else:
|
|
completed, current_word = tokens[:-1], tokens[-1]
|
|
|
|
# The chain must be unbroken: every completed token is a distinct
|
|
# skill command, and there's room left under the cap.
|
|
seen: set[str] = set()
|
|
for token in completed:
|
|
key = self._normalize_skill_token(token)
|
|
if key not in self._iter_skill_commands() or key in seen:
|
|
return
|
|
seen.add(key)
|
|
if len(seen) >= _cap:
|
|
return
|
|
|
|
# Only suggest while the user is typing another /token — a bare
|
|
# space after the chain means they may be starting the instruction.
|
|
if not current_word.startswith("/"):
|
|
return
|
|
|
|
word_key = self._normalize_skill_token(current_word)
|
|
for cmd, info in self._iter_skill_commands().items():
|
|
if cmd in seen or not cmd.startswith(word_key):
|
|
continue
|
|
# Exact match: append a trailing space so the dropdown stays
|
|
# visible and the next stacked token can be typed immediately
|
|
# (mirrors _completion_text semantics).
|
|
replacement = f"{cmd} " if cmd == word_key else cmd
|
|
yield Completion(
|
|
replacement,
|
|
start_position=-len(current_word),
|
|
display=cmd,
|
|
display_meta=f"⚡ {_short_desc(info, 'Skill command')}",
|
|
)
|
|
|
|
# Commands that open pickers when run bare. No trailing space for these:
|
|
# the TUI applies the completion on Enter, and "/model " blocks the picker.
|
|
_PICKER_COMMANDS = frozenset({"model", "skin", "personality"})
|
|
|
|
@staticmethod
|
|
def _completion_text(cmd_name: str, word: str) -> str:
|
|
"""Return replacement text for a completion.
|
|
|
|
On an exact match (``/help`` fully typed) a no-op replacement makes
|
|
prompt_toolkit suppress the menu, so a trailing space is appended to
|
|
keep the dropdown visible — except for _PICKER_COMMANDS.
|
|
"""
|
|
if cmd_name != word or cmd_name in SlashCommandCompleter._PICKER_COMMANDS:
|
|
return cmd_name
|
|
return f"{cmd_name} "
|
|
|
|
@staticmethod
|
|
def _extract_path_word(text: str) -> str | None:
|
|
"""Return the path-like word under the cursor, else None.
|
|
|
|
Path-like: starts with ``./``, ``../``, ``~/``, ``/`` or contains a
|
|
``/``. Tokens with a ``://`` scheme are excluded — treating a pasted
|
|
URL as a path fires os.listdir per keystroke for no useful result.
|
|
"""
|
|
# Words are space-delimited, but paths can contain almost anything.
|
|
word = text.rpartition(" ")[2]
|
|
if not word or "://" in word:
|
|
return None
|
|
# Only trigger path completion for path-like tokens
|
|
if word.startswith(("./", "../", "~/", "/")) or "/" in word:
|
|
return word
|
|
return None
|
|
|
|
@staticmethod
|
|
def _dir_completions(
|
|
expanded: str,
|
|
word: str,
|
|
limit: int,
|
|
text_for: Callable[[str], str],
|
|
want_dir: bool | None = None,
|
|
):
|
|
"""Yield directory-listing completions for the path *expanded*.
|
|
|
|
Entries of the parent dir are matched case-insensitively on the typed
|
|
basename (all entries after a trailing ``/``), sorted by name, and
|
|
limited to *limit*. ``text_for(full_path)`` builds the completion text
|
|
(without the trailing ``/``); *want_dir* restricts to dirs / files.
|
|
"""
|
|
if expanded.endswith("/"):
|
|
search_dir, prefix = expanded, ""
|
|
else:
|
|
search_dir = os.path.dirname(expanded) or "."
|
|
prefix = os.path.basename(expanded)
|
|
try:
|
|
entries = os.listdir(search_dir)
|
|
except OSError:
|
|
return
|
|
prefix_lower = prefix.lower()
|
|
count = 0
|
|
for entry in sorted(entries):
|
|
if prefix and not entry.lower().startswith(prefix_lower):
|
|
continue
|
|
full_path = os.path.join(search_dir, entry)
|
|
is_dir = os.path.isdir(full_path)
|
|
if want_dir is not None and want_dir != is_dir:
|
|
continue
|
|
if count >= limit:
|
|
break
|
|
suffix = "/" if is_dir else ""
|
|
yield Completion(
|
|
text_for(full_path) + suffix,
|
|
start_position=-len(word),
|
|
display=entry + suffix,
|
|
display_meta="dir" if is_dir else _file_size_label(full_path),
|
|
)
|
|
count += 1
|
|
|
|
@staticmethod
|
|
def _path_completions(word: str, limit: int = 30):
|
|
"""Yield Completion objects for file paths matching *word*."""
|
|
# Completion text keeps the user's own path style (~, absolute, relative).
|
|
if word.startswith("~"):
|
|
text_for = lambda fp: "~/" + os.path.relpath(fp, os.path.expanduser("~")) # noqa: E731
|
|
elif os.path.isabs(word):
|
|
text_for = lambda fp: fp # noqa: E731
|
|
else:
|
|
text_for = os.path.relpath
|
|
yield from SlashCommandCompleter._dir_completions(
|
|
os.path.expanduser(word), word, limit, text_for
|
|
)
|
|
|
|
@staticmethod
|
|
def _extract_context_word(text: str) -> str | None:
|
|
"""Extract a bare ``@`` token for context reference completions."""
|
|
word = text.rpartition(" ")[2]
|
|
return word if word.startswith("@") else None
|
|
|
|
def _context_completions(self, word: str, limit: int = 30):
|
|
"""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: / @folder: (or just @file / @folder with
|
|
# no colon yet), delegate to path completions. Accepting the bare
|
|
# form lets the picker surface directories as soon as the user has
|
|
# typed `@folder`, without requiring them to first accept the static
|
|
# `@folder:` hint and re-trigger completion.
|
|
for prefix in ("@file:", "@folder:"):
|
|
bare = prefix[:-1]
|
|
|
|
if word == bare or word.startswith(prefix):
|
|
path_part = '' if word == bare else word[len(prefix):]
|
|
expanded = os.path.expanduser(path_part)
|
|
if not expanded or expanded == ".":
|
|
expanded = "./"
|
|
# `@folder:` surfaces only directories, `@file:` only regular
|
|
# files — otherwise `@folder:` lists every dotfile in cwd.
|
|
yield from self._dir_completions(
|
|
expanded, word, limit,
|
|
lambda fp: f"{prefix}{os.path.relpath(fp)}",
|
|
want_dir=(prefix == "@folder:"),
|
|
)
|
|
return
|
|
|
|
# Bare @ or @partial — fuzzy project-wide file search
|
|
query = word[1:] # strip the @
|
|
yield from self._fuzzy_file_completions(word, query, limit)
|
|
|
|
def _get_project_files(self) -> list[str]:
|
|
"""Return cached list of project files (refreshed every 5s)."""
|
|
cwd = os.getcwd()
|
|
now = time.monotonic()
|
|
if (
|
|
self._file_cache
|
|
and self._file_cache_cwd == cwd
|
|
and now - self._file_cache_time < 5.0
|
|
):
|
|
return self._file_cache
|
|
|
|
files: list[str] = []
|
|
# Try rg first (fast, respects .gitignore), then fd, then find.
|
|
for cmd in [
|
|
["rg", "--files", "--sortr=modified", cwd],
|
|
["rg", "--files", cwd],
|
|
["fd", "--type", "f", "--base-directory", cwd],
|
|
]:
|
|
tool = cmd[0]
|
|
if not shutil.which(tool):
|
|
continue
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=2,
|
|
cwd=cwd, encoding="utf-8", errors="replace",
|
|
)
|
|
if proc.returncode == 0 and proc.stdout and proc.stdout.strip():
|
|
raw = proc.stdout.strip().split("\n")
|
|
# Store relative paths
|
|
for p in raw[:5000]:
|
|
try:
|
|
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
|
|
except ValueError:
|
|
# Windows: relpath raises for paths on a different
|
|
# mount than cwd — device paths (\\.\nul, \\.\con)
|
|
# or another drive letter. One bad entry must not
|
|
# crash the @ autocomplete event loop (#42016).
|
|
continue
|
|
files.append(rel)
|
|
break
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
continue
|
|
|
|
self._file_cache = files
|
|
self._file_cache_time = now
|
|
self._file_cache_cwd = cwd
|
|
return files
|
|
|
|
@staticmethod
|
|
def _score_path(filepath: str, query: str) -> int:
|
|
"""Score a file path against a fuzzy query. Higher = better match."""
|
|
if not query:
|
|
return 1 # show everything when query is empty
|
|
|
|
filename = os.path.basename(filepath)
|
|
lower_file = filename.lower()
|
|
lower_path = filepath.lower()
|
|
lower_q = query.lower()
|
|
|
|
# Exact filename match
|
|
if lower_file == lower_q:
|
|
return 100
|
|
# Filename starts with query
|
|
if lower_file.startswith(lower_q):
|
|
return 80
|
|
# Filename contains query as substring
|
|
if lower_q in lower_file:
|
|
return 60
|
|
# Full path contains query
|
|
if lower_q in lower_path:
|
|
return 40
|
|
# Initials / abbreviation match: e.g. "fo" matches "file_operations"
|
|
# Check if query chars appear in order in filename
|
|
qi = 0
|
|
for c in lower_file:
|
|
if qi < len(lower_q) and c == lower_q[qi]:
|
|
qi += 1
|
|
if qi == len(lower_q):
|
|
# Bonus if matches land on word boundaries (after _, -, /, .)
|
|
boundary_hits = 0
|
|
qi = 0
|
|
prev = "_" # treat start as boundary
|
|
for c in lower_file:
|
|
if qi < len(lower_q) and c == lower_q[qi]:
|
|
if prev in "_-./":
|
|
boundary_hits += 1
|
|
qi += 1
|
|
prev = c
|
|
if boundary_hits >= len(lower_q) * 0.5:
|
|
return 35
|
|
return 25
|
|
return 0
|
|
|
|
def _fuzzy_file_completions(self, word: str, query: str, limit: int = 20):
|
|
"""Yield fuzzy file completions for bare @query."""
|
|
files = self._get_project_files()
|
|
|
|
if not query:
|
|
# No query — recently modified files (already mtime-sorted).
|
|
ranked = files[:limit]
|
|
else:
|
|
scored = [(s, fp) for fp in files if (s := self._score_path(fp, query)) > 0]
|
|
scored.sort(key=lambda x: (-x[0], x[1]))
|
|
ranked = [fp for _, fp in scored[:limit]]
|
|
|
|
for fp in ranked:
|
|
is_dir = fp.endswith("/")
|
|
kind = "folder" if is_dir else "file"
|
|
meta = "dir" if is_dir else _file_size_label(os.path.join(os.getcwd(), fp))
|
|
if query:
|
|
meta = f"{fp} {meta}" if meta else fp
|
|
yield Completion(
|
|
f"@{kind}:{fp}",
|
|
start_position=-len(word),
|
|
display=os.path.basename(fp),
|
|
display_meta=meta,
|
|
)
|
|
|
|
@staticmethod
|
|
def _skin_completions(sub_text: str, sub_lower: str):
|
|
"""Yield completions for /skin from available skins."""
|
|
try:
|
|
from hermes_cli.skin_engine import list_skins
|
|
for s in list_skins():
|
|
name = s["name"]
|
|
if name.startswith(sub_lower) and name != sub_lower:
|
|
yield Completion(
|
|
name,
|
|
start_position=-len(sub_text),
|
|
display=name,
|
|
display_meta=s.get("description", "") or s.get("source", ""),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
@staticmethod
|
|
def _tools_completions(sub_text: str, sub_lower: str):
|
|
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
|
|
|
|
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
|
|
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
|
|
keys and MCP server prefixes, filtered by current enable state so the
|
|
user only sees actionable options).
|
|
"""
|
|
SUBS = ("list", "disable", "enable")
|
|
parts = sub_text.split()
|
|
trailing_space = sub_text.endswith(" ")
|
|
|
|
# Subcommand stage: zero words typed, or completing the first word.
|
|
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
|
|
partial = sub_text if not trailing_space else ""
|
|
for sub in SUBS:
|
|
if sub.startswith(partial.lower()) and sub != partial.lower():
|
|
yield Completion(sub, start_position=-len(partial), display=sub)
|
|
return
|
|
|
|
subcommand = parts[0].lower()
|
|
if subcommand not in ("enable", "disable"):
|
|
return
|
|
|
|
partial = "" if trailing_space else parts[-1]
|
|
partial_lower = partial.lower()
|
|
already = set(parts[1:] if trailing_space else parts[1:-1])
|
|
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
from hermes_cli.tools_config import (
|
|
CONFIGURABLE_TOOLSETS,
|
|
_get_platform_tools,
|
|
_get_plugin_toolset_keys,
|
|
)
|
|
|
|
# Readonly loader: this runs per keystroke and never mutates config,
|
|
# so skip the defensive deepcopy of load_config().
|
|
config = load_config_readonly()
|
|
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
|
mcp_servers = config.get("mcp_servers") or {}
|
|
|
|
# (candidate, meta, actionable): toolsets are only offered when the
|
|
# subcommand would change their state (enable → off, disable → on);
|
|
# MCP server prefixes are always offered.
|
|
rows = [(k, label, (subcommand == "enable") != (k in enabled)) for k, label, _d in CONFIGURABLE_TOOLSETS]
|
|
rows += [(k, "plugin toolset", (subcommand == "enable") != (k in enabled)) for k in sorted(_get_plugin_toolset_keys())]
|
|
if isinstance(mcp_servers, dict):
|
|
rows += [(f"{srv}:", f"MCP server '{srv}'", True) for srv in sorted(mcp_servers)]
|
|
for key, meta, actionable in rows:
|
|
if actionable and key not in already and key.startswith(partial_lower):
|
|
yield Completion(key, start_position=-len(partial), display=key, display_meta=meta)
|
|
except Exception:
|
|
return
|
|
|
|
@staticmethod
|
|
def _handoff_completions(sub_text: str, sub_lower: str):
|
|
"""Yield platform completions for /handoff.
|
|
|
|
Offers connected (enabled + configured) gateway platforms. A recorded
|
|
home channel is NOT required to list a platform — it's often learned at
|
|
runtime — so the meta hints whether one is set yet. Completes only the
|
|
first arg (the platform); once one is chosen, stop.
|
|
"""
|
|
parts = sub_text.split()
|
|
trailing_space = sub_text.endswith(" ")
|
|
if len(parts) > 1 or (len(parts) == 1 and trailing_space):
|
|
return
|
|
partial = "" if (not parts or trailing_space) else parts[-1]
|
|
partial_lower = partial.lower()
|
|
try:
|
|
from gateway.config import load_gateway_config
|
|
|
|
gw = load_gateway_config()
|
|
platforms = gw.get_connected_platforms()
|
|
except Exception:
|
|
return
|
|
for platform in platforms:
|
|
name = platform.value
|
|
if not name.startswith(partial_lower):
|
|
continue
|
|
try:
|
|
home = gw.get_home_channel(platform)
|
|
except Exception:
|
|
home = None
|
|
meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here"
|
|
yield Completion(
|
|
name,
|
|
start_position=-len(partial),
|
|
display=name,
|
|
display_meta=meta,
|
|
)
|
|
|
|
@staticmethod
|
|
def _personality_completions(sub_text: str, sub_lower: str):
|
|
"""Yield completions for /personality via hermes_cli.personality."""
|
|
try:
|
|
from hermes_cli.personality import describe_personality
|
|
|
|
personalities = _personalities_from_cli_config()
|
|
|
|
if "none".startswith(sub_lower) and "none" != sub_lower:
|
|
yield Completion(
|
|
"none",
|
|
start_position=-len(sub_text),
|
|
display="none",
|
|
display_meta="clear personality overlay",
|
|
)
|
|
for name, prompt in personalities.items():
|
|
if name.startswith(sub_lower) and name != sub_lower:
|
|
yield Completion(
|
|
name,
|
|
start_position=-len(sub_text),
|
|
display=name,
|
|
display_meta=describe_personality(prompt),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# base command -> (handler(sub_text, sub_lower), single_word_only).
|
|
# Single-word handlers only run while the first argument is being typed;
|
|
# /tools and /handoff parse multi-word input themselves, bypassing the
|
|
# static SUBCOMMANDS branch.
|
|
_DYNAMIC_COMPLETIONS: dict[str, tuple[Callable[..., Any], bool]] = {
|
|
"/skin": (_skin_completions, True),
|
|
"/personality": (_personality_completions, True),
|
|
"/tools": (_tools_completions, False),
|
|
"/handoff": (_handoff_completions, False),
|
|
}
|
|
|
|
def get_completions(self, document, complete_event):
|
|
text = document.text_before_cursor
|
|
if not text.startswith("/"):
|
|
# 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
|
|
# 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)
|
|
return
|
|
|
|
# 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()
|
|
|
|
# Stacked slash-skill chain (`/skill-a /skill-b …`), see
|
|
# split_stacked_skill_commands in agent/skill_commands.py.
|
|
if self._is_skill_command(base_cmd):
|
|
yield from self._stacked_skill_completions(text)
|
|
return
|
|
|
|
# Dynamic completions for commands with runtime lists.
|
|
dynamic = self._DYNAMIC_COMPLETIONS.get(base_cmd)
|
|
if dynamic is not None:
|
|
handler, single_word = dynamic
|
|
if not single_word or " " not in sub_text:
|
|
yield from handler(sub_text, sub_lower)
|
|
return
|
|
|
|
# Static subcommand completions
|
|
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
|
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
|
|
|
|
word = text[1:]
|
|
|
|
def _cmd_completion(cmd_name: str, meta: str):
|
|
return Completion(
|
|
self._completion_text(cmd_name, word),
|
|
start_position=-len(word),
|
|
display=f"/{cmd_name}",
|
|
display_meta=meta,
|
|
)
|
|
|
|
for cmd, desc in COMMANDS.items():
|
|
if not self._command_allowed(cmd):
|
|
continue
|
|
if cmd[1:].startswith(word):
|
|
yield _cmd_completion(cmd[1:], desc)
|
|
|
|
for cmd, info in self._iter_skill_bundles().items():
|
|
if cmd[1:].startswith(word):
|
|
skill_count = len(info.get("skills", []))
|
|
yield _cmd_completion(
|
|
cmd[1:],
|
|
f"▣ {_short_desc(info, 'Skill bundle')} ({skill_count} skills)",
|
|
)
|
|
|
|
for cmd, info in self._iter_skill_commands().items():
|
|
if cmd[1:].startswith(word):
|
|
yield _cmd_completion(cmd[1:], f"⚡ {_short_desc(info, 'Skill command')}")
|
|
|
|
# Plugin-registered slash commands
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_commands
|
|
for cmd_name, cmd_info in get_plugin_commands().items():
|
|
if cmd_name.startswith(word):
|
|
yield _cmd_completion(cmd_name, f"🔌 {_short_desc(cmd_info, 'Plugin command')}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
if not text.startswith("/"):
|
|
return self._history.get_suggestion(buffer, document) if self._history else 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 → "ate". Prefer the SHORTEST
|
|
# match so /he ghosts "lp" (/help), not "artbeat" (/heartbeat).
|
|
word = text[1:].lower()
|
|
for cmd in sorted(COMMANDS, key=len):
|
|
if self._completer is not None and not self._completer._command_allowed(cmd):
|
|
continue
|
|
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
|
|
sub_text = parts[1] if len(parts) > 1 else ""
|
|
sub_lower = sub_text.lower()
|
|
|
|
# Stacked skill chain: ghost-suggest the rest of the next skill name;
|
|
# otherwise fall through to the history fallback for instruction text.
|
|
if self._completer is not None and self._completer._is_skill_command(base_cmd):
|
|
for completion in self._completer._stacked_skill_completions(text):
|
|
remainder = completion.text[-completion.start_position:] \
|
|
if completion.start_position else completion.text
|
|
if remainder.strip():
|
|
return Suggestion(remainder)
|
|
|
|
# Static subcommands
|
|
if self._completer is not None and not self._completer._command_allowed(base_cmd):
|
|
return None
|
|
if " " not in sub_text:
|
|
for sub in SUBCOMMANDS.get(base_cmd, ()):
|
|
if sub.startswith(sub_lower) and sub != sub_lower:
|
|
return Suggestion(sub[len(sub_text):])
|
|
|
|
return self._history.get_suggestion(buffer, document) if self._history else None
|
|
|
|
|
|
def _short_desc(info: Mapping[str, Any], default: str) -> str:
|
|
"""50-char description preview used in completion menus."""
|
|
description = str(info.get("description", default))
|
|
return description[:50] + ("..." if len(description) > 50 else "")
|
|
|
|
|
|
def _file_size_label(path: str) -> str:
|
|
"""Return a compact human-readable file size, or '' on error."""
|
|
try:
|
|
size = os.path.getsize(path)
|
|
except OSError:
|
|
return ""
|
|
if size < 1024:
|
|
return f"{size}B"
|
|
if size < 1024 * 1024:
|
|
return f"{size / 1024:.0f}K"
|
|
if size < 1024 * 1024 * 1024:
|
|
return f"{size / (1024 * 1024):.1f}M"
|
|
return f"{size / (1024 * 1024 * 1024):.1f}G"
|