/ tests / hermes_cli / test_plugin_cli_registration.py
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")