diff --git a/cli.py b/cli.py index 094be22e97..6df6932290 100755 --- a/cli.py +++ b/cli.py @@ -3094,8 +3094,33 @@ 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. + # Matches against both built-in COMMANDS and installed skill commands so + # that execution-time resolution agrees with tab-completion. + from hermes_cli.commands import COMMANDS + typed_base = cmd_lower.split()[0] + all_known = set(COMMANDS) | set(_skill_commands) + matches = [c for c in all_known if c.startswith(typed_base)] + if len(matches) == 1: + # Expand the prefix to the full command name, preserving arguments. + # Guard against redispatching the same token to avoid infinite + # recursion when the expanded name still doesn't hit an exact branch + # (e.g. /config with extra args that are not yet handled above). + full_name = matches[0] + if full_name == typed_base: + # Already an exact token — no expansion possible; fall through + self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") + self.console.print("[dim #B8860B]Type /help for available commands[/]") + else: + remainder = cmd_original.strip()[len(typed_base):] + full_cmd = full_name + 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 0000000000..ffec91957f --- /dev/null +++ b/tests/test_cli_prefix_matching.py @@ -0,0 +1,117 @@ +"""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 = [] + cli_obj.session_id = None + cli_obj._pending_input = MagicMock() + 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_does_not_recurse(self): + """/con set key value should expand to /config set key value without infinite recursion.""" + cli_obj = _make_cli() + dispatched = [] + + original = cli_obj.process_command.__func__ + + def counting_process_command(self_inner, cmd): + dispatched.append(cmd) + if len(dispatched) > 5: + raise RecursionError("process_command called too many times") + return original(self_inner, cmd) + + with patch.object(type(cli_obj), 'process_command', counting_process_command): + try: + cli_obj.process_command("/con set key value") + except RecursionError: + assert False, "process_command recursed infinitely" + + # Should have been called at most twice: once for /con set..., once for /config set... + assert len(dispatched) <= 2 + + def test_exact_command_with_args_does_not_recurse(self): + """/config set key value hits exact branch and does not loop back to prefix.""" + cli_obj = _make_cli() + call_count = [0] + + original_pc = HermesCLI.process_command + + def guarded(self_inner, cmd): + call_count[0] += 1 + if call_count[0] > 10: + raise RecursionError("Infinite recursion detected") + return original_pc(self_inner, cmd) + + with patch.object(HermesCLI, 'process_command', guarded): + try: + cli_obj.process_command("/config set key value") + except RecursionError: + assert False, "Recursed infinitely on /config set key value" + + assert call_count[0] <= 3 + + def test_ambiguous_prefix_shows_suggestions(self): + """/re matches multiple commands — should show ambiguous message.""" + cli_obj = _make_cli() + cli_obj.process_command("/re") + 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() + + def test_skill_command_prefix_matches(self): + """A prefix that uniquely matches a skill command should dispatch it.""" + cli_obj = _make_cli() + fake_skill = {"/test-skill-xyz": {"name": "Test Skill", "description": "test"}} + printed = [] + cli_obj.console.print = lambda *a, **kw: printed.append(str(a)) + + import cli as cli_mod + with patch.object(cli_mod, '_skill_commands', fake_skill): + cli_obj.process_command("/test-skill-xy") + + # Should NOT show "Unknown command" — should have dispatched or attempted skill + unknown = any("Unknown command" in p for p in printed) + assert not unknown, f"Expected skill prefix to match, got: {printed}" + + def test_ambiguous_between_builtin_and_skill(self): + """Ambiguous prefix spanning builtin + skill commands shows suggestions.""" + cli_obj = _make_cli() + # /help-extra is a fake skill that shares /hel prefix with /help + fake_skill = {"/help-extra": {"name": "Help Extra", "description": "test"}} + + import cli as cli_mod + with patch.object(cli_mod, '_skill_commands', fake_skill), patch.object(cli_obj, 'show_help') as mock_help: + cli_obj.process_command("/help") + + # /help is an exact match so should work normally, not show ambiguous + mock_help.assert_called_once() + printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + assert "Ambiguous" not in printed