diff --git a/cli.py b/cli.py index 1914769c5..30a9e9161 100755 --- a/cli.py +++ b/cli.py @@ -2481,7 +2481,69 @@ class HermesCLI: print(f" Total: {len(tools)} tools ヽ(^o^)ノ") print() - + + def _handle_tools_command(self, cmd: str): + """Handle /tools [list|disable|enable] slash commands. + + /tools (no args) shows the tool list. + /tools list shows enabled/disabled status per toolset. + /tools disable/enable saves the change to config and resets + the session so the new tool set takes effect cleanly (no + prompt-cache breakage mid-conversation). + """ + import shlex + from argparse import Namespace + from hermes_cli.tools_config import tools_disable_enable_command + + try: + parts = shlex.split(cmd) + except ValueError: + parts = cmd.split() + + subcommand = parts[1] if len(parts) > 1 else "" + if subcommand not in ("list", "disable", "enable"): + self.show_tools() + return + + if subcommand == "list": + tools_disable_enable_command( + Namespace(tools_action="list", platform="cli")) + return + + names = parts[2:] + if not names: + print(f"(._.) Usage: /tools {subcommand} [name ...]") + print(f" Built-in toolset: /tools {subcommand} web") + print(f" MCP tool: /tools {subcommand} github:create_issue") + return + + # Confirm session reset before applying + verb = "Disable" if subcommand == "disable" else "Enable" + label = ", ".join(names) + _cprint(f"{_GOLD}{verb} {label}?{_RST}") + _cprint(f"{_DIM}This will save to config and reset your session so the " + f"change takes effect cleanly.{_RST}") + try: + answer = input(" Continue? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + _cprint(f"{_DIM}Cancelled.{_RST}") + return + + if answer not in ("y", "yes"): + _cprint(f"{_DIM}Cancelled.{_RST}") + return + + tools_disable_enable_command( + Namespace(tools_action=subcommand, names=names, platform="cli")) + + # Reset session so the new tool config is picked up from a clean state + from hermes_cli.tools_config import _get_platform_tools + from hermes_cli.config import load_config + self.enabled_toolsets = _get_platform_tools(load_config(), "cli") + self.new_session() + _cprint(f"{_DIM}Session reset. New tool configuration is active.{_RST}") + def show_toolsets(self): """Display available toolsets with kawaii ASCII art.""" all_toolsets = get_all_toolsets() @@ -3279,7 +3341,7 @@ class HermesCLI: elif canonical == "help": self.show_help() elif canonical == "tools": - self.show_tools() + self._handle_tools_command(cmd_original) elif canonical == "toolsets": self.show_toolsets() elif canonical == "config": @@ -3610,6 +3672,18 @@ class HermesCLI: 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: + # Prefer an exact match (typed the full command name) + exact = [c for c in matches if c == typed_base] + if len(exact) == 1: + matches = exact + else: + # Prefer the unique shortest match: + # /qui → /quit (5) wins over /quint-pipeline (15) + min_len = min(len(c) for c in matches) + shortest = [c for c in matches if len(c) == min_len] + if len(shortest) == 1: + matches = shortest if len(matches) == 1: # Expand the prefix to the full command name, preserving arguments. # Guard against redispatching the same token to avoid infinite diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f8d50e356..a0bb04a23 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -92,8 +92,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), # Tools & Skills - CommandDef("tools", "List available tools", "Tools & Skills", - cli_only=True), + CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills", + args_hint="[list|disable|enable] [name...]", cli_only=True), CommandDef("toolsets", "List available toolsets", "Tools & Skills", cli_only=True), CommandDef("skills", "Search, install, inspect, or manage skills", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 266a4cee2..9bbf480c8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3147,17 +3147,66 @@ For more help on a command: tools_parser = subparsers.add_parser( "tools", help="Configure which tools are enabled per platform", - description="Interactive tool configuration — enable/disable tools for CLI, Telegram, Discord, etc." + description=( + "Enable, disable, or list tools for CLI, Telegram, Discord, etc.\n\n" + "Built-in toolsets use plain names (e.g. web, memory).\n" + "MCP tools use server:tool notation (e.g. github:create_issue).\n\n" + "Run 'hermes tools' with no subcommand for the interactive configuration UI." + ), ) tools_parser.add_argument( "--summary", action="store_true", help="Print a summary of enabled tools per platform and exit" ) + tools_sub = tools_parser.add_subparsers(dest="tools_action") + + # hermes tools list [--platform cli] + tools_list_p = tools_sub.add_parser( + "list", + help="Show all tools and their enabled/disabled status", + ) + tools_list_p.add_argument( + "--platform", default="cli", + help="Platform to show (default: cli)", + ) + + # hermes tools disable [--platform cli] + tools_disable_p = tools_sub.add_parser( + "disable", + help="Disable toolsets or MCP tools", + ) + tools_disable_p.add_argument( + "names", nargs="+", metavar="NAME", + help="Toolset name (e.g. web) or MCP tool in server:tool form", + ) + tools_disable_p.add_argument( + "--platform", default="cli", + help="Platform to apply to (default: cli)", + ) + + # hermes tools enable [--platform cli] + tools_enable_p = tools_sub.add_parser( + "enable", + help="Enable toolsets or MCP tools", + ) + tools_enable_p.add_argument( + "names", nargs="+", metavar="NAME", + help="Toolset name or MCP tool in server:tool form", + ) + tools_enable_p.add_argument( + "--platform", default="cli", + help="Platform to apply to (default: cli)", + ) def cmd_tools(args): - from hermes_cli.tools_config import tools_command - tools_command(args) + action = getattr(args, "tools_action", None) + if action in ("list", "disable", "enable"): + from hermes_cli.tools_config import tools_disable_enable_command + tools_disable_enable_command(args) + else: + from hermes_cli.tools_config import tools_command + tools_command(args) tools_parser.set_defaults(func=cmd_tools) # ========================================================================= diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 186100c02..d106d0c47 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1088,3 +1088,114 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): print(color(" Tool configuration saved to ~/.hermes/config.yaml", Colors.DIM)) print(color(" Changes take effect on next 'hermes' or gateway restart.", Colors.DIM)) print() + + +# ─── Non-interactive disable/enable ────────────────────────────────────────── + + +def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str): + """Add or remove built-in toolsets for a platform.""" + enabled = _get_platform_tools(config, platform) + if action == "disable": + updated = enabled - set(toolset_names) + else: + updated = enabled | set(toolset_names) + _save_platform_tools(config, platform, updated) + + +def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str]: + """Add or remove specific MCP tools from a server's exclude list. + + Returns the set of server names that were not found in config. + """ + failed_servers: Set[str] = set() + mcp_servers = config.get("mcp_servers") or {} + + for target in targets: + server_name, tool_name = target.split(":", 1) + if server_name not in mcp_servers: + failed_servers.add(server_name) + continue + tools_cfg = mcp_servers[server_name].setdefault("tools", {}) + exclude = list(tools_cfg.get("exclude") or []) + if action == "disable": + if tool_name not in exclude: + exclude.append(tool_name) + else: + exclude = [t for t in exclude if t != tool_name] + tools_cfg["exclude"] = exclude + + return failed_servers + + +def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"): + """Print a summary of enabled/disabled toolsets and MCP tool filters.""" + print(f"Built-in toolsets ({platform}):") + for ts_key, label, _ in CONFIGURABLE_TOOLSETS: + status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets + else color("✗ disabled", Colors.RED)) + print(f" {status} {ts_key} {color(label, Colors.DIM)}") + + if mcp_servers: + print() + print("MCP servers:") + for srv_name, srv_cfg in mcp_servers.items(): + tools_cfg = srv_cfg.get("tools") or {} + exclude = tools_cfg.get("exclude") or [] + include = tools_cfg.get("include") or [] + if include: + _print_info(f"{srv_name} [include only: {', '.join(include)}]") + elif exclude: + _print_info(f"{srv_name} [excluded: {color(', '.join(exclude), Colors.YELLOW)}]") + else: + _print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}") + + +def tools_disable_enable_command(args): + """Enable, disable, or list tools for a platform. + + Built-in toolsets use plain names (e.g. ``web``, ``memory``). + MCP tools use ``server:tool`` notation (e.g. ``github:create_issue``). + """ + action = args.tools_action + platform = getattr(args, "platform", "cli") + config = load_config() + + if platform not in PLATFORMS: + _print_error(f"Unknown platform '{platform}'. Valid: {', '.join(PLATFORMS)}") + return + + if action == "list": + _print_tools_list(_get_platform_tools(config, platform), + config.get("mcp_servers") or {}, platform) + return + + targets: List[str] = args.names + toolset_targets = [t for t in targets if ":" not in t] + mcp_targets = [t for t in targets if ":" in t] + + valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + unknown_toolsets = [t for t in toolset_targets if t not in valid_toolsets] + if unknown_toolsets: + for name in unknown_toolsets: + _print_error(f"Unknown toolset '{name}'") + toolset_targets = [t for t in toolset_targets if t in valid_toolsets] + + if toolset_targets: + _apply_toolset_change(config, platform, toolset_targets, action) + + failed_servers: Set[str] = set() + if mcp_targets: + failed_servers = _apply_mcp_change(config, mcp_targets, action) + for srv in failed_servers: + _print_error(f"MCP server '{srv}' not found in config") + + save_config(config) + + successful = [ + t for t in targets + if t not in unknown_toolsets and (":" not in t or t.split(":")[0] not in failed_servers) + ] + if successful: + verb = "Disabled" if action == "disable" else "Enabled" + _print_success(f"{verb}: {', '.join(successful)}") diff --git a/tests/hermes_cli/test_tools_disable_enable.py b/tests/hermes_cli/test_tools_disable_enable.py new file mode 100644 index 000000000..0976533b1 --- /dev/null +++ b/tests/hermes_cli/test_tools_disable_enable.py @@ -0,0 +1,207 @@ +"""Tests for hermes tools disable/enable/list command (backend).""" +from argparse import Namespace +from unittest.mock import patch + +from hermes_cli.tools_config import tools_disable_enable_command + + +# ── Built-in toolset disable ──────────────────────────────────────────────── + + +class TestToolsDisableBuiltin: + + def test_disable_removes_toolset_from_platform(self): + config = {"platform_toolsets": {"cli": ["web", "memory", "terminal"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command(Namespace(tools_action="disable", names=["web"], platform="cli")) + saved = mock_save.call_args[0][0] + assert "web" not in saved["platform_toolsets"]["cli"] + assert "memory" in saved["platform_toolsets"]["cli"] + + def test_disable_multiple_toolsets(self): + config = {"platform_toolsets": {"cli": ["web", "memory", "terminal"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command(Namespace(tools_action="disable", names=["web", "memory"], platform="cli")) + saved = mock_save.call_args[0][0] + assert "web" not in saved["platform_toolsets"]["cli"] + assert "memory" not in saved["platform_toolsets"]["cli"] + assert "terminal" in saved["platform_toolsets"]["cli"] + + def test_disable_already_absent_is_idempotent(self): + config = {"platform_toolsets": {"cli": ["memory"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command(Namespace(tools_action="disable", names=["web"], platform="cli")) + saved = mock_save.call_args[0][0] + assert "web" not in saved["platform_toolsets"]["cli"] + + +# ── Built-in toolset enable ───────────────────────────────────────────────── + + +class TestToolsEnableBuiltin: + + def test_enable_adds_toolset_to_platform(self): + config = {"platform_toolsets": {"cli": ["memory"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command(Namespace(tools_action="enable", names=["web"], platform="cli")) + saved = mock_save.call_args[0][0] + assert "web" in saved["platform_toolsets"]["cli"] + + def test_enable_already_present_is_idempotent(self): + config = {"platform_toolsets": {"cli": ["web"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command(Namespace(tools_action="enable", names=["web"], platform="cli")) + saved = mock_save.call_args[0][0] + assert saved["platform_toolsets"]["cli"].count("web") == 1 + + +# ── MCP tool disable ──────────────────────────────────────────────────────── + + +class TestToolsDisableMcp: + + def test_disable_adds_to_exclude_list(self): + config = {"mcp_servers": {"github": {"command": "npx"}}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command( + Namespace(tools_action="disable", names=["github:create_issue"], platform="cli") + ) + saved = mock_save.call_args[0][0] + assert "create_issue" in saved["mcp_servers"]["github"]["tools"]["exclude"] + + def test_disable_already_excluded_is_idempotent(self): + config = {"mcp_servers": {"github": {"tools": {"exclude": ["create_issue"]}}}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command( + Namespace(tools_action="disable", names=["github:create_issue"], platform="cli") + ) + saved = mock_save.call_args[0][0] + assert saved["mcp_servers"]["github"]["tools"]["exclude"].count("create_issue") == 1 + + def test_disable_unknown_server_prints_error(self, capsys): + config = {"mcp_servers": {}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config"): + tools_disable_enable_command( + Namespace(tools_action="disable", names=["unknown:tool"], platform="cli") + ) + out = capsys.readouterr().out + assert "MCP server 'unknown' not found in config" in out + + +# ── MCP tool enable ────────────────────────────────────────────────────────── + + +class TestToolsEnableMcp: + + def test_enable_removes_from_exclude_list(self): + config = {"mcp_servers": {"github": {"tools": {"exclude": ["create_issue", "delete_branch"]}}}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command( + Namespace(tools_action="enable", names=["github:create_issue"], platform="cli") + ) + saved = mock_save.call_args[0][0] + assert "create_issue" not in saved["mcp_servers"]["github"]["tools"]["exclude"] + assert "delete_branch" in saved["mcp_servers"]["github"]["tools"]["exclude"] + + +# ── Mixed targets ──────────────────────────────────────────────────────────── + + +class TestToolsMixedTargets: + + def test_disable_builtin_and_mcp_together(self): + config = { + "platform_toolsets": {"cli": ["web", "memory"]}, + "mcp_servers": {"github": {"command": "npx"}}, + } + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command(Namespace( + tools_action="disable", + names=["web", "github:create_issue"], + platform="cli", + )) + saved = mock_save.call_args[0][0] + assert "web" not in saved["platform_toolsets"]["cli"] + assert "create_issue" in saved["mcp_servers"]["github"]["tools"]["exclude"] + + +# ── List output ────────────────────────────────────────────────────────────── + + +class TestToolsList: + + def test_list_shows_enabled_toolsets(self, capsys): + config = {"platform_toolsets": {"cli": ["web", "memory"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config): + tools_disable_enable_command(Namespace(tools_action="list", platform="cli")) + out = capsys.readouterr().out + assert "web" in out + assert "memory" in out + + def test_list_shows_mcp_excluded_tools(self, capsys): + config = { + "mcp_servers": {"github": {"tools": {"exclude": ["create_issue"]}}}, + } + with patch("hermes_cli.tools_config.load_config", return_value=config): + tools_disable_enable_command(Namespace(tools_action="list", platform="cli")) + out = capsys.readouterr().out + assert "github" in out + assert "create_issue" in out + + +# ── Validation ─────────────────────────────────────────────────────────────── + + +class TestToolsValidation: + + def test_unknown_platform_prints_error(self, capsys): + config = {} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config"): + tools_disable_enable_command( + Namespace(tools_action="disable", names=["web"], platform="invalid_platform") + ) + out = capsys.readouterr().out + assert "Unknown platform 'invalid_platform'" in out + + def test_unknown_toolset_prints_error(self, capsys): + config = {"platform_toolsets": {"cli": ["web"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config"): + tools_disable_enable_command( + Namespace(tools_action="disable", names=["nonexistent_toolset"], platform="cli") + ) + out = capsys.readouterr().out + assert "Unknown toolset 'nonexistent_toolset'" in out + + def test_unknown_toolset_does_not_corrupt_config(self): + config = {"platform_toolsets": {"cli": ["web", "memory"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command( + Namespace(tools_action="disable", names=["nonexistent_toolset"], platform="cli") + ) + saved = mock_save.call_args[0][0] + assert "web" in saved["platform_toolsets"]["cli"] + assert "memory" in saved["platform_toolsets"]["cli"] + + def test_mixed_valid_and_invalid_applies_valid_only(self): + config = {"platform_toolsets": {"cli": ["web", "memory"]}} + with patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as mock_save: + tools_disable_enable_command( + Namespace(tools_action="disable", names=["web", "bad_toolset"], platform="cli") + ) + saved = mock_save.call_args[0][0] + assert "web" not in saved["platform_toolsets"]["cli"] + assert "memory" in saved["platform_toolsets"]["cli"] diff --git a/tests/test_cli_prefix_matching.py b/tests/test_cli_prefix_matching.py index eafa324f3..eb773def2 100644 --- a/tests/test_cli_prefix_matching.py +++ b/tests/test_cli_prefix_matching.py @@ -121,3 +121,40 @@ class TestSlashCommandPrefixMatching: mock_help.assert_called_once() printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) assert "Ambiguous" not in printed + + def test_shortest_match_preferred_over_longer_skill(self): + """/qui should dispatch to /quit (5 chars) not report ambiguous with /quint-pipeline (15 chars).""" + cli_obj = _make_cli() + fake_skill = {"/quint-pipeline": {"name": "Quint Pipeline", "description": "test"}} + + import cli as cli_mod + with patch.object(cli_mod, '_skill_commands', fake_skill): + # /quit is caught by the exact "/quit" branch → process_command returns False + result = cli_obj.process_command("/qui") + + # Returns False because /quit was dispatched (exits chat loop) + assert result is False + printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + assert "Ambiguous" not in printed + + def test_tied_shortest_matches_still_ambiguous(self): + """/re matches /reset and /retry (both 6 chars) — no unique shortest, stays ambiguous.""" + cli_obj = _make_cli() + printed = [] + import cli as cli_mod + with patch.object(cli_mod, '_cprint', side_effect=lambda t: printed.append(t)): + cli_obj.process_command("/re") + combined = " ".join(printed) + assert "Ambiguous" in combined or "Did you mean" in combined + + def test_exact_typed_name_dispatches_over_longer_match(self): + """/help typed with /help-extra skill installed → exact match wins.""" + cli_obj = _make_cli() + fake_skill = {"/help-extra": {"name": "Help Extra", "description": ""}} + 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") + mock_help.assert_called_once() + printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + assert "Ambiguous" not in printed diff --git a/tests/test_cli_tools_command.py b/tests/test_cli_tools_command.py new file mode 100644 index 000000000..9e648aecb --- /dev/null +++ b/tests/test_cli_tools_command.py @@ -0,0 +1,121 @@ +"""Tests for /tools slash command handler in the interactive CLI.""" + +from unittest.mock import MagicMock, patch, call + +from cli import HermesCLI + + +def _make_cli(enabled_toolsets=None): + """Build a minimal HermesCLI stub without running __init__.""" + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.enabled_toolsets = set(enabled_toolsets or ["web", "memory"]) + cli_obj._command_running = False + cli_obj.console = MagicMock() + return cli_obj + + +# ── /tools (no subcommand) ────────────────────────────────────────────────── + + +class TestToolsSlashNoSubcommand: + + def test_bare_tools_shows_tool_list(self): + cli_obj = _make_cli() + with patch.object(cli_obj, "show_tools") as mock_show: + cli_obj._handle_tools_command("/tools") + mock_show.assert_called_once() + + def test_unknown_subcommand_falls_back_to_show_tools(self): + cli_obj = _make_cli() + with patch.object(cli_obj, "show_tools") as mock_show: + cli_obj._handle_tools_command("/tools foobar") + mock_show.assert_called_once() + + +# ── /tools list ───────────────────────────────────────────────────────────── + + +class TestToolsSlashList: + + def test_list_calls_backend(self, capsys): + cli_obj = _make_cli() + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web"]}}), \ + patch("hermes_cli.tools_config.save_config"): + cli_obj._handle_tools_command("/tools list") + out = capsys.readouterr().out + assert "web" in out + + def test_list_does_not_modify_enabled_toolsets(self): + """List is read-only — self.enabled_toolsets must not change.""" + cli_obj = _make_cli(["web", "memory"]) + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web"]}}): + cli_obj._handle_tools_command("/tools list") + assert cli_obj.enabled_toolsets == {"web", "memory"} + + +# ── /tools disable (session reset) ────────────────────────────────────────── + + +class TestToolsSlashDisableWithReset: + + def test_disable_confirms_then_resets_session(self): + cli_obj = _make_cli(["web", "memory"]) + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ + patch("hermes_cli.tools_config.save_config"), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(cli_obj, "new_session") as mock_reset, \ + patch("builtins.input", return_value="y"): + cli_obj._handle_tools_command("/tools disable web") + mock_reset.assert_called_once() + assert "web" not in cli_obj.enabled_toolsets + + def test_disable_cancelled_does_not_reset(self): + cli_obj = _make_cli(["web", "memory"]) + with patch.object(cli_obj, "new_session") as mock_reset, \ + patch("builtins.input", return_value="n"): + cli_obj._handle_tools_command("/tools disable web") + mock_reset.assert_not_called() + # Toolsets unchanged + assert cli_obj.enabled_toolsets == {"web", "memory"} + + def test_disable_eof_cancels(self): + cli_obj = _make_cli(["web", "memory"]) + with patch.object(cli_obj, "new_session") as mock_reset, \ + patch("builtins.input", side_effect=EOFError): + cli_obj._handle_tools_command("/tools disable web") + mock_reset.assert_not_called() + + def test_disable_missing_name_prints_usage(self, capsys): + cli_obj = _make_cli() + cli_obj._handle_tools_command("/tools disable") + out = capsys.readouterr().out + assert "Usage" in out + + +# ── /tools enable (session reset) ─────────────────────────────────────────── + + +class TestToolsSlashEnableWithReset: + + def test_enable_confirms_then_resets_session(self): + cli_obj = _make_cli(["memory"]) + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["memory"]}}), \ + patch("hermes_cli.tools_config.save_config"), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(cli_obj, "new_session") as mock_reset, \ + patch("builtins.input", return_value="y"): + cli_obj._handle_tools_command("/tools enable web") + mock_reset.assert_called_once() + assert "web" in cli_obj.enabled_toolsets + + def test_enable_missing_name_prints_usage(self, capsys): + cli_obj = _make_cli() + cli_obj._handle_tools_command("/tools enable") + out = capsys.readouterr().out + assert "Usage" in out