diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a6907d044..fb0cf0a85 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4732,106 +4732,23 @@ For more help on a command: plugins_parser.set_defaults(func=cmd_plugins) # ========================================================================= - # honcho command — Honcho-specific config (peer, mode, tokens, profiles) - # Provider selection happens via 'hermes memory setup'. + # Plugin CLI commands — dynamically registered by memory/general plugins. + # Plugins provide a register_cli(subparser) function that builds their + # own argparse tree. No hardcoded plugin commands in main.py. # ========================================================================= - honcho_parser = subparsers.add_parser( - "honcho", - help="Manage Honcho memory provider config (peer, mode, profiles)", - description=( - "Configure Honcho-specific settings. Honcho is now a memory provider\n" - "plugin — initial setup is via 'hermes memory setup'. These commands\n" - "manage Honcho's own config: peer names, memory mode, token budgets,\n" - "per-profile host blocks, and cross-profile observability." - ), - formatter_class=__import__("argparse").RawDescriptionHelpFormatter, - ) - honcho_parser.add_argument( - "--target-profile", metavar="NAME", dest="target_profile", - help="Target a specific profile's Honcho config without switching", - ) - honcho_subparsers = honcho_parser.add_subparsers(dest="honcho_command") - - honcho_subparsers.add_parser("setup", help="Initial Honcho setup (redirects to hermes memory setup)") - honcho_status = honcho_subparsers.add_parser("status", help="Show current Honcho config and connection status") - honcho_status.add_argument("--all", action="store_true", help="Show config overview across all profiles") - honcho_subparsers.add_parser("peers", help="Show peer identities across all profiles") - honcho_subparsers.add_parser("sessions", help="List known Honcho session mappings") - - honcho_map = honcho_subparsers.add_parser( - "map", help="Map current directory to a Honcho session name (no arg = list mappings)" - ) - honcho_map.add_argument( - "session_name", nargs="?", default=None, - help="Session name to associate with this directory. Omit to list current mappings.", - ) - - honcho_peer = honcho_subparsers.add_parser( - "peer", help="Show or update peer names and dialectic reasoning level" - ) - honcho_peer.add_argument("--user", metavar="NAME", help="Set user peer name") - honcho_peer.add_argument("--ai", metavar="NAME", help="Set AI peer name") - honcho_peer.add_argument( - "--reasoning", - metavar="LEVEL", - choices=("minimal", "low", "medium", "high", "max"), - help="Set default dialectic reasoning level (minimal/low/medium/high/max)", - ) - - honcho_mode = honcho_subparsers.add_parser( - "mode", help="Show or set memory mode (hybrid/honcho/local)" - ) - honcho_mode.add_argument( - "mode", nargs="?", metavar="MODE", - choices=("hybrid", "honcho", "local"), - help="Memory mode to set (hybrid/honcho/local). Omit to show current.", - ) - - honcho_tokens = honcho_subparsers.add_parser( - "tokens", help="Show or set token budget for context and dialectic" - ) - honcho_tokens.add_argument( - "--context", type=int, metavar="N", - help="Max tokens Honcho returns from session.context() per turn", - ) - honcho_tokens.add_argument( - "--dialectic", type=int, metavar="N", - help="Max chars of dialectic result to inject into system prompt", - ) - - honcho_identity = honcho_subparsers.add_parser( - "identity", help="Seed or show the AI peer's Honcho identity representation" - ) - honcho_identity.add_argument( - "file", nargs="?", default=None, - help="Path to file to seed from (e.g. SOUL.md). Omit to show usage.", - ) - honcho_identity.add_argument( - "--show", action="store_true", - help="Show current AI peer representation from Honcho", - ) - - honcho_subparsers.add_parser( - "migrate", - help="Step-by-step migration guide from openclaw-honcho to Hermes Honcho", - ) - honcho_subparsers.add_parser("enable", help="Enable Honcho for the active profile") - honcho_subparsers.add_parser("disable", help="Disable Honcho for the active profile") - honcho_subparsers.add_parser("sync", help="Sync Honcho config to all existing profiles") - - def cmd_honcho(args): - sub = getattr(args, "honcho_command", None) - if sub == "setup": - # Redirect to the generic memory setup - print("\n Honcho is now configured via the memory provider system.") - print(" Running 'hermes memory setup'...\n") - from hermes_cli.memory_setup import memory_command - memory_command(args) - return - from plugins.memory.honcho.cli import honcho_command - honcho_command(args) - - honcho_parser.set_defaults(func=cmd_honcho) + try: + from plugins.memory import discover_plugin_cli_commands + for cmd_info in discover_plugin_cli_commands(): + plugin_parser = subparsers.add_parser( + cmd_info["name"], + help=cmd_info["help"], + description=cmd_info.get("description", ""), + formatter_class=__import__("argparse").RawDescriptionHelpFormatter, + ) + cmd_info["setup_fn"](plugin_parser) + except Exception as _exc: + import logging as _log + _log.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) # ========================================================================= # memory command diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index dfb0b584f..98dacf131 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -182,6 +182,32 @@ class PluginContext: cli._pending_input.put(msg) return True + # -- CLI command registration -------------------------------------------- + + def register_cli_command( + self, + name: str, + help: str, + setup_fn: Callable, + handler_fn: Callable | None = None, + description: str = "", + ) -> None: + """Register a CLI subcommand (e.g. ``hermes honcho ...``). + + The *setup_fn* receives an argparse subparser and should add any + arguments/sub-subparsers. If *handler_fn* is provided it is set + as the default dispatch function via ``set_defaults(func=...)``. + """ + self._manager._cli_commands[name] = { + "name": name, + "help": help, + "description": description, + "setup_fn": setup_fn, + "handler_fn": handler_fn, + "plugin": self.manifest.name, + } + logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name) + # -- hook registration -------------------------------------------------- def register_hook(self, hook_name: str, callback: Callable) -> None: @@ -213,6 +239,7 @@ class PluginManager: self._plugins: Dict[str, LoadedPlugin] = {} self._hooks: Dict[str, List[Callable]] = {} self._plugin_tool_names: Set[str] = set() + self._cli_commands: Dict[str, dict] = {} self._discovered: bool = False self._cli_ref = None # Set by CLI after plugin discovery @@ -526,6 +553,15 @@ def get_plugin_tool_names() -> Set[str]: return get_plugin_manager()._plugin_tool_names +def get_plugin_cli_commands() -> Dict[str, dict]: + """Return CLI commands registered by general plugins. + + Returns a dict of ``{name: {help, setup_fn, handler_fn, ...}}`` + suitable for wiring into argparse subparsers. + """ + return dict(get_plugin_manager()._cli_commands) + + def get_plugin_toolsets() -> List[tuple]: """Return plugin toolsets as ``(key, label, description)`` tuples. diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index 6d8ef5994..e0ed4d90f 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -211,3 +211,80 @@ class _ProviderCollector: def register_hook(self, *args, **kwargs): pass + + def register_cli_command(self, *args, **kwargs): + pass # CLI registration happens via discover_plugin_cli_commands() + + +def discover_plugin_cli_commands() -> List[dict]: + """Scan memory plugin directories for CLI command registrations. + + 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``. + + This is a lightweight scan — it only imports ``cli.py``, not the + full plugin module. Safe to call during argparse setup before + any provider is loaded. + """ + results: 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 + + 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) + + register_cli = getattr(cli_mod, "register_cli", None) + if not callable(register_cli): + continue + + # 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 + + handler_fn = getattr(cli_mod, "honcho_command", None) or \ + getattr(cli_mod, f"{child.name}_command", None) + + 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) + + return results diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index a413c8dbe..1735c0065 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -1176,8 +1176,15 @@ def honcho_command(args) -> None: _profile_override = getattr(args, "target_profile", None) sub = getattr(args, "honcho_command", None) - if sub == "setup" or sub is None: - cmd_setup(args) + if sub == "setup": + # Redirect to memory setup — honcho setup goes through the unified path + print("\n Honcho is configured via the memory provider system.") + print(" Running 'hermes memory setup'...\n") + from hermes_cli.memory_setup import cmd_setup_provider + cmd_setup_provider("honcho") + return + elif sub is None: + cmd_status(args) elif sub == "status": cmd_status(args) elif sub == "peers": @@ -1204,4 +1211,96 @@ def honcho_command(args) -> None: cmd_sync(args) else: print(f" Unknown honcho command: {sub}") - print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n") + print(" Available: status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n") + + +def register_cli(subparser) -> None: + """Build the ``hermes honcho`` argparse subcommand tree. + + Called by the plugin CLI registration system during argparse setup. + The *subparser* is the parser for ``hermes honcho``. + """ + import argparse + + subparser.add_argument( + "--target-profile", metavar="NAME", dest="target_profile", + help="Target a specific profile's Honcho config without switching", + ) + subs = subparser.add_subparsers(dest="honcho_command") + + subs.add_parser( + "setup", + help="Initial Honcho setup (redirects to hermes memory setup)", + ) + + status_parser = subs.add_parser( + "status", help="Show current Honcho config and connection status", + ) + status_parser.add_argument( + "--all", action="store_true", help="Show config overview across all profiles", + ) + + subs.add_parser("peers", help="Show peer identities across all profiles") + subs.add_parser("sessions", help="List known Honcho session mappings") + + map_parser = subs.add_parser( + "map", help="Map current directory to a Honcho session name (no arg = list mappings)", + ) + map_parser.add_argument( + "session_name", nargs="?", default=None, + help="Session name to associate with this directory. Omit to list current mappings.", + ) + + peer_parser = subs.add_parser( + "peer", help="Show or update peer names and dialectic reasoning level", + ) + peer_parser.add_argument("--user", metavar="NAME", help="Set user peer name") + peer_parser.add_argument("--ai", metavar="NAME", help="Set AI peer name") + peer_parser.add_argument( + "--reasoning", metavar="LEVEL", + choices=("minimal", "low", "medium", "high", "max"), + help="Set default dialectic reasoning level (minimal/low/medium/high/max)", + ) + + mode_parser = subs.add_parser( + "mode", help="Show or set recall mode (hybrid/context/tools)", + ) + mode_parser.add_argument( + "mode", nargs="?", metavar="MODE", + choices=("hybrid", "context", "tools"), + help="Recall mode to set (hybrid/context/tools). Omit to show current.", + ) + + tokens_parser = subs.add_parser( + "tokens", help="Show or set token budget for context and dialectic", + ) + tokens_parser.add_argument( + "--context", type=int, metavar="N", + help="Max tokens Honcho returns from session.context() per turn", + ) + tokens_parser.add_argument( + "--dialectic", type=int, metavar="N", + help="Max chars of dialectic result to inject into system prompt", + ) + + identity_parser = subs.add_parser( + "identity", help="Seed or show the AI peer's Honcho identity representation", + ) + identity_parser.add_argument( + "file", nargs="?", default=None, + help="Path to file to seed from (e.g. SOUL.md). Omit to show usage.", + ) + identity_parser.add_argument( + "--show", action="store_true", + help="Show current AI peer representation from Honcho", + ) + + subs.add_parser( + "migrate", + help="Step-by-step migration guide from openclaw-honcho to Hermes Honcho", + ) + subs.add_parser("enable", help="Enable Honcho for the active profile") + subs.add_parser("disable", help="Disable Honcho for the active profile") + subs.add_parser("sync", help="Sync Honcho config to all existing profiles") + + subparser.set_defaults(func=honcho_command)