diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index e0ed4d90f02..cd583e6d8df 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -216,12 +216,33 @@ class _ProviderCollector: pass # CLI registration happens via discover_plugin_cli_commands() -def discover_plugin_cli_commands() -> List[dict]: - """Scan memory plugin directories for CLI command registrations. +def _get_active_memory_provider() -> Optional[str]: + """Read the active memory provider name from config.yaml. - Looks for a ``register_cli(subparser)`` function in each plugin's - ``cli.py``. Returns a list of dicts with keys: - ``name``, ``help``, ``description``, ``setup_fn``, ``handler_fn``. + Returns the provider name (e.g. ``"honcho"``) or None if no + external provider is configured. Lightweight — only reads config, + no plugin loading. + """ + try: + from hermes_cli.config import load_config + config = load_config() + return config.get("memory", {}).get("provider") or None + except Exception: + return None + + +def discover_plugin_cli_commands() -> List[dict]: + """Return CLI commands for the **active** memory plugin only. + + Only one memory provider can be active at a time (set via + ``memory.provider`` in config.yaml). This function reads that + value and only loads CLI registration for the matching plugin. + If no provider is active, no commands are registered. + + Looks for a ``register_cli(subparser)`` function in the active + plugin's ``cli.py``. Returns a list of at most one dict with + keys: ``name``, ``help``, ``description``, ``setup_fn``, + ``handler_fn``. This is a lightweight scan — it only imports ``cli.py``, not the full plugin module. Safe to call during argparse setup before @@ -231,60 +252,66 @@ def discover_plugin_cli_commands() -> List[dict]: if not _MEMORY_PLUGINS_DIR.is_dir(): return results - for child in sorted(_MEMORY_PLUGINS_DIR.iterdir()): - if not child.is_dir() or child.name.startswith(("_", ".")): - continue - cli_file = child / "cli.py" - if not cli_file.exists(): - continue + active_provider = _get_active_memory_provider() + if not active_provider: + return results - module_name = f"plugins.memory.{child.name}.cli" - try: - # Import the CLI module (lightweight — no SDK needed) - if module_name in sys.modules: - cli_mod = sys.modules[module_name] - else: - spec = importlib.util.spec_from_file_location( - module_name, str(cli_file) - ) - if not spec or not spec.loader: - continue - cli_mod = importlib.util.module_from_spec(spec) - sys.modules[module_name] = cli_mod - spec.loader.exec_module(cli_mod) + # Only look at the active provider's directory + plugin_dir = _MEMORY_PLUGINS_DIR / active_provider + if not plugin_dir.is_dir(): + return results - register_cli = getattr(cli_mod, "register_cli", None) - if not callable(register_cli): - continue + cli_file = plugin_dir / "cli.py" + if not cli_file.exists(): + return results - # Read metadata from plugin.yaml if available - help_text = f"Manage {child.name} memory plugin" - description = "" - yaml_file = child / "plugin.yaml" - if yaml_file.exists(): - try: - import yaml - with open(yaml_file) as f: - meta = yaml.safe_load(f) or {} - desc = meta.get("description", "") - if desc: - help_text = desc - description = desc - except Exception: - pass + module_name = f"plugins.memory.{active_provider}.cli" + try: + # Import the CLI module (lightweight — no SDK needed) + if module_name in sys.modules: + cli_mod = sys.modules[module_name] + else: + spec = importlib.util.spec_from_file_location( + module_name, str(cli_file) + ) + if not spec or not spec.loader: + return results + cli_mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = cli_mod + spec.loader.exec_module(cli_mod) - handler_fn = getattr(cli_mod, "honcho_command", None) or \ - getattr(cli_mod, f"{child.name}_command", None) + register_cli = getattr(cli_mod, "register_cli", None) + if not callable(register_cli): + return results - results.append({ - "name": child.name, - "help": help_text, - "description": description, - "setup_fn": register_cli, - "handler_fn": handler_fn, - "plugin": child.name, - }) - except Exception as e: - logger.debug("Failed to scan CLI for memory plugin '%s': %s", child.name, e) + # Read metadata from plugin.yaml if available + help_text = f"Manage {active_provider} memory plugin" + description = "" + yaml_file = plugin_dir / "plugin.yaml" + if yaml_file.exists(): + try: + import yaml + with open(yaml_file) as f: + meta = yaml.safe_load(f) or {} + desc = meta.get("description", "") + if desc: + help_text = desc + description = desc + except Exception: + pass + + handler_fn = getattr(cli_mod, f"{active_provider}_command", None) or \ + getattr(cli_mod, "honcho_command", None) + + results.append({ + "name": active_provider, + "help": help_text, + "description": description, + "setup_fn": register_cli, + "handler_fn": handler_fn, + "plugin": active_provider, + }) + except Exception as e: + logger.debug("Failed to scan CLI for memory plugin '%s': %s", active_provider, e) return results diff --git a/tests/test_plugin_cli_registration.py b/tests/test_plugin_cli_registration.py index 8dd8b193cba..76c9aaa062c 100644 --- a/tests/test_plugin_cli_registration.py +++ b/tests/test_plugin_cli_registration.py @@ -80,8 +80,8 @@ class TestGetPluginCliCommands: class TestMemoryPluginCliDiscovery: - def test_discovers_plugin_with_register_cli(self, tmp_path, monkeypatch): - """A memory plugin dir with cli.py containing register_cli is discovered.""" + def test_discovers_active_plugin_with_register_cli(self, tmp_path, monkeypatch): + """Only the active memory provider's CLI commands are discovered.""" plugin_dir = tmp_path / "testplugin" plugin_dir.mkdir() (plugin_dir / "__init__.py").write_text("pass\n") @@ -96,29 +96,58 @@ class TestMemoryPluginCliDiscovery: "name: testplugin\ndescription: A test plugin\n" ) - # Patch _MEMORY_PLUGINS_DIR to our tmp dir + # Also create a second plugin that should NOT be discovered + other_dir = tmp_path / "otherplugin" + other_dir.mkdir() + (other_dir / "__init__.py").write_text("pass\n") + (other_dir / "cli.py").write_text( + "def register_cli(subparser):\n" + " subparser.add_argument('--other')\n" + ) + import plugins.memory as pm original_dir = pm._MEMORY_PLUGINS_DIR - - # Clear any cached module to force reimport mod_key = "plugins.memory.testplugin.cli" sys.modules.pop(mod_key, None) monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) + # Set testplugin as the active provider + monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "testplugin") try: cmds = pm.discover_plugin_cli_commands() finally: monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir) sys.modules.pop(mod_key, None) + # Only testplugin should be discovered, not otherplugin assert len(cmds) == 1 assert cmds[0]["name"] == "testplugin" assert cmds[0]["help"] == "A test plugin" assert callable(cmds[0]["setup_fn"]) assert cmds[0]["handler_fn"].__name__ == "testplugin_command" + def test_returns_nothing_when_no_active_provider(self, tmp_path, monkeypatch): + """No commands when memory.provider is not set in config.""" + plugin_dir = tmp_path / "testplugin" + plugin_dir.mkdir() + (plugin_dir / "__init__.py").write_text("pass\n") + (plugin_dir / "cli.py").write_text( + "def register_cli(subparser):\n pass\n" + ) + + import plugins.memory as pm + original_dir = pm._MEMORY_PLUGINS_DIR + monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) + monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: None) + try: + cmds = pm.discover_plugin_cli_commands() + finally: + monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir) + + assert len(cmds) == 0 + def test_skips_plugin_without_register_cli(self, tmp_path, monkeypatch): - """A memory plugin with cli.py but no register_cli is skipped.""" + """An active plugin with cli.py but no register_cli returns nothing.""" plugin_dir = tmp_path / "noplugin" plugin_dir.mkdir() (plugin_dir / "__init__.py").write_text("pass\n") @@ -127,6 +156,7 @@ class TestMemoryPluginCliDiscovery: import plugins.memory as pm original_dir = pm._MEMORY_PLUGINS_DIR monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) + monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "noplugin") try: cmds = pm.discover_plugin_cli_commands() finally: @@ -136,7 +166,7 @@ class TestMemoryPluginCliDiscovery: assert len(cmds) == 0 def test_skips_plugin_without_cli_py(self, tmp_path, monkeypatch): - """A memory plugin dir without cli.py is skipped.""" + """An active provider without cli.py returns nothing.""" plugin_dir = tmp_path / "nocli" plugin_dir.mkdir() (plugin_dir / "__init__.py").write_text("pass\n") @@ -144,6 +174,7 @@ class TestMemoryPluginCliDiscovery: import plugins.memory as pm original_dir = pm._MEMORY_PLUGINS_DIR monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path) + monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "nocli") try: cmds = pm.discover_plugin_cli_commands() finally: