From a50550fdb442b2dced799332a2f9b63a23a80888 Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Wed, 11 Mar 2026 21:11:04 +0300 Subject: [PATCH] fix: add prefix matching to slash command dispatcher Slash commands previously required exact full names. Typing /con returned 'Unknown command' even though /config was the only match. Add unambiguous prefix matching in process_command(): - Unique prefix (e.g. /con -> /config): dispatch immediately - Ambiguous prefix (e.g. /re -> /reset, /retry, /reasoning...): show 'Did you mean' suggestions - No match: existing 'Unknown command' error Prefix matching uses the COMMANDS dict from hermes_cli/commands.py (same source as SlashCommandCompleter) so it stays in sync with any new commands added there. Closes #928 --- cli.py | 17 +++++++-- tests/test_cli_prefix_matching.py | 60 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_prefix_matching.py diff --git a/cli.py b/cli.py index 094be22e9..84cf22767 100755 --- a/cli.py +++ b/cli.py @@ -3094,8 +3094,21 @@ class HermesCLI: else: self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]") else: - self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") - self.console.print("[dim #B8860B]Type /help for available commands[/]") + # Prefix matching: if input uniquely identifies one command, execute it + from hermes_cli.commands import COMMANDS + typed_base = cmd_lower.split()[0] + matches = [c for c in COMMANDS if c.startswith(typed_base)] + if len(matches) == 1: + # Re-dispatch with the full command name, preserving any arguments + remainder = cmd_original.strip()[len(typed_base):] + full_cmd = matches[0] + remainder + return self.process_command(full_cmd) + elif len(matches) > 1: + self.console.print(f"[bold yellow]Ambiguous command: {cmd_lower}[/]") + self.console.print(f"[dim]Did you mean: {', '.join(sorted(matches))}?[/]") + else: + self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") + self.console.print("[dim #B8860B]Type /help for available commands[/]") return True diff --git a/tests/test_cli_prefix_matching.py b/tests/test_cli_prefix_matching.py new file mode 100644 index 000000000..b7419a8aa --- /dev/null +++ b/tests/test_cli_prefix_matching.py @@ -0,0 +1,60 @@ +"""Tests for slash command prefix matching in HermesCLI.process_command.""" +from unittest.mock import MagicMock, patch +from cli import HermesCLI + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + return cli_obj + + +class TestSlashCommandPrefixMatching: + def test_unique_prefix_dispatches_command(self): + """/con should dispatch to /config when it uniquely matches.""" + cli_obj = _make_cli() + with patch.object(cli_obj, 'show_config') as mock_config: + cli_obj.process_command("/con") + mock_config.assert_called_once() + + def test_unique_prefix_with_args_dispatches_command(self): + """/mo with argument should dispatch to /model.""" + cli_obj = _make_cli() + with patch.object(cli_obj, 'process_command', wraps=cli_obj.process_command): + with patch("hermes_cli.models.fetch_api_models", return_value=None), \ + patch("cli.save_config_value"): + cli_obj.model = "current-model" + cli_obj.provider = "openrouter" + cli_obj.base_url = "https://openrouter.ai/api/v1" + cli_obj.api_key = "test" + cli_obj._explicit_api_key = None + cli_obj._explicit_base_url = None + cli_obj.requested_provider = "openrouter" + # /mod uniquely matches /model + result = cli_obj.process_command("/mod") + assert result is True + + def test_ambiguous_prefix_shows_suggestions(self): + """/re matches /reset, /retry, /reload-mcp, /reasoning, /rollback — should show suggestions.""" + cli_obj = _make_cli() + cli_obj.process_command("/re") + # Should print ambiguous message, not unknown command + printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + assert "Ambiguous" in printed or "Did you mean" in printed + + def test_unknown_command_shows_error(self): + """/xyz should show unknown command error.""" + cli_obj = _make_cli() + cli_obj.process_command("/xyz") + printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + assert "Unknown command" in printed + + def test_exact_command_still_works(self): + """/help should still work as exact match.""" + cli_obj = _make_cli() + with patch.object(cli_obj, 'show_help') as mock_help: + cli_obj.process_command("/help") + mock_help.assert_called_once()