test_plugin_cli_registration.py
1 """Tests for plugin CLI registration system. 2 3 Covers: 4 - PluginContext.register_cli_command() 5 - PluginManager._cli_commands storage 6 - get_plugin_cli_commands() convenience function 7 - Memory plugin CLI discovery (discover_plugin_cli_commands) 8 - Honcho register_cli() builds correct argparse tree 9 """ 10 11 import argparse 12 import os 13 import sys 14 from pathlib import Path 15 from unittest.mock import MagicMock 16 17 import pytest 18 19 from hermes_cli.plugins import ( 20 PluginContext, 21 PluginManager, 22 PluginManifest, 23 ) 24 25 26 # ── PluginContext.register_cli_command ───────────────────────────────────── 27 28 29 class TestRegisterCliCommand: 30 def _make_ctx(self): 31 mgr = PluginManager() 32 manifest = PluginManifest(name="test-plugin") 33 return PluginContext(manifest, mgr), mgr 34 35 def test_registers_command(self): 36 ctx, mgr = self._make_ctx() 37 setup = MagicMock() 38 handler = MagicMock() 39 ctx.register_cli_command( 40 name="mycmd", 41 help="Do something", 42 setup_fn=setup, 43 handler_fn=handler, 44 description="Full description", 45 ) 46 assert "mycmd" in mgr._cli_commands 47 entry = mgr._cli_commands["mycmd"] 48 assert entry["name"] == "mycmd" 49 assert entry["help"] == "Do something" 50 assert entry["setup_fn"] is setup 51 assert entry["handler_fn"] is handler 52 assert entry["plugin"] == "test-plugin" 53 54 def test_overwrites_on_duplicate(self): 55 ctx, mgr = self._make_ctx() 56 ctx.register_cli_command("x", "first", MagicMock()) 57 ctx.register_cli_command("x", "second", MagicMock()) 58 assert mgr._cli_commands["x"]["help"] == "second" 59 60 def test_handler_optional(self): 61 ctx, mgr = self._make_ctx() 62 ctx.register_cli_command("nocb", "test", MagicMock()) 63 assert mgr._cli_commands["nocb"]["handler_fn"] is None 64 65 66 # ── Memory plugin CLI discovery ─────────────────────────────────────────── 67 68 69 class TestMemoryPluginCliDiscovery: 70 def test_discovers_active_plugin_with_register_cli(self, tmp_path, monkeypatch): 71 """Only the active memory provider's CLI commands are discovered.""" 72 plugin_dir = tmp_path / "testplugin" 73 plugin_dir.mkdir() 74 (plugin_dir / "__init__.py").write_text("pass\n") 75 (plugin_dir / "cli.py").write_text( 76 "def register_cli(subparser):\n" 77 " subparser.add_argument('--test')\n" 78 "\n" 79 "def testplugin_command(args):\n" 80 " pass\n" 81 ) 82 (plugin_dir / "plugin.yaml").write_text( 83 "name: testplugin\ndescription: A test plugin\n" 84 ) 85 86 # Also create a second plugin that should NOT be discovered 87 other_dir = tmp_path / "otherplugin" 88 other_dir.mkdir() 89 (other_dir / "__init__.py").write_text("pass\n") 90 (other_dir / "cli.py").write_text( 91 "def register_cli(subparser):\n" 92 " subparser.add_argument('--other')\n" 93 ) 94 95 import plugins.memory as pm 96 original_dir = pm._MEMORY_PLUGINS_DIR 97 mod_key = "plugins.memory.testplugin.cli" 98 sys.modules.pop(mod_key, None) 99 100 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) 101 # Set testplugin as the active provider 102 monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "testplugin") 103 try: 104 cmds = pm.discover_plugin_cli_commands() 105 finally: 106 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir) 107 sys.modules.pop(mod_key, None) 108 109 # Only testplugin should be discovered, not otherplugin 110 assert len(cmds) == 1 111 assert cmds[0]["name"] == "testplugin" 112 assert cmds[0]["help"] == "A test plugin" 113 assert callable(cmds[0]["setup_fn"]) 114 assert cmds[0]["handler_fn"].__name__ == "testplugin_command" 115 116 def test_returns_nothing_when_no_active_provider(self, tmp_path, monkeypatch): 117 """No commands when memory.provider is not set in config.""" 118 plugin_dir = tmp_path / "testplugin" 119 plugin_dir.mkdir() 120 (plugin_dir / "__init__.py").write_text("pass\n") 121 (plugin_dir / "cli.py").write_text( 122 "def register_cli(subparser):\n pass\n" 123 ) 124 125 import plugins.memory as pm 126 original_dir = pm._MEMORY_PLUGINS_DIR 127 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) 128 monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: None) 129 try: 130 cmds = pm.discover_plugin_cli_commands() 131 finally: 132 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir) 133 134 assert len(cmds) == 0 135 136 def test_skips_plugin_without_register_cli(self, tmp_path, monkeypatch): 137 """An active plugin with cli.py but no register_cli returns nothing.""" 138 plugin_dir = tmp_path / "noplugin" 139 plugin_dir.mkdir() 140 (plugin_dir / "__init__.py").write_text("pass\n") 141 (plugin_dir / "cli.py").write_text("def some_other_fn():\n pass\n") 142 143 import plugins.memory as pm 144 original_dir = pm._MEMORY_PLUGINS_DIR 145 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) 146 monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "noplugin") 147 try: 148 cmds = pm.discover_plugin_cli_commands() 149 finally: 150 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir) 151 sys.modules.pop("plugins.memory.noplugin.cli", None) 152 153 assert len(cmds) == 0 154 155 def test_skips_plugin_without_cli_py(self, tmp_path, monkeypatch): 156 """An active provider without cli.py returns nothing.""" 157 plugin_dir = tmp_path / "nocli" 158 plugin_dir.mkdir() 159 (plugin_dir / "__init__.py").write_text("pass\n") 160 161 import plugins.memory as pm 162 original_dir = pm._MEMORY_PLUGINS_DIR 163 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) 164 monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "nocli") 165 try: 166 cmds = pm.discover_plugin_cli_commands() 167 finally: 168 monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir) 169 170 assert len(cmds) == 0 171 172 173 # ── Honcho register_cli ────────────────────────────────────────────────── 174 175 176 # ── ProviderCollector no-op ────────────────────────────────────────────── 177 178 179 class TestProviderCollectorCliNoop: 180 def test_register_cli_command_is_noop(self): 181 """_ProviderCollector.register_cli_command is a no-op (doesn't crash).""" 182 from plugins.memory import _ProviderCollector 183 184 collector = _ProviderCollector() 185 collector.register_cli_command( 186 name="test", help="test", setup_fn=lambda s: None 187 ) 188 # Should not store anything — CLI is discovered via file convention 189 assert not hasattr(collector, "_cli_commands")