diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py new file mode 100644 index 000000000..acd7c57bf --- /dev/null +++ b/hermes_cli/completion.py @@ -0,0 +1,223 @@ +"""Shell completion script generation for hermes CLI. + +Walks the live argparse parser tree to generate accurate, always-up-to-date +completion scripts — no hardcoded subcommand lists, no extra dependencies. + +Supports bash, zsh, and fish. +""" + +from __future__ import annotations + +import argparse +from typing import Any + + +def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]: + """Recursively extract subcommands and flags from a parser. + + Uses _SubParsersAction._choices_actions to get canonical names (no aliases) + along with their help text. + """ + flags: list[str] = [] + subcommands: dict[str, Any] = {} + + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + # _choices_actions has one entry per canonical name; aliases are + # omitted, which keeps completion lists clean. + seen: set[str] = set() + for pseudo in action._choices_actions: + name = pseudo.dest + if name in seen: + continue + seen.add(name) + subparser = action.choices.get(name) + if subparser is None: + continue + info = _walk(subparser) + info["help"] = _clean(pseudo.help or "") + subcommands[name] = info + elif action.option_strings: + flags.extend(o for o in action.option_strings if o.startswith("-")) + + return {"flags": flags, "subcommands": subcommands} + + +def _clean(text: str, maxlen: int = 60) -> str: + """Strip shell-unsafe characters and truncate.""" + return text.replace("'", "").replace('"', "").replace("\\", "")[:maxlen] + + +# --------------------------------------------------------------------------- +# Bash +# --------------------------------------------------------------------------- + +def generate_bash(parser: argparse.ArgumentParser) -> str: + tree = _walk(parser) + top_cmds = " ".join(sorted(tree["subcommands"])) + + cases: list[str] = [] + for cmd in sorted(tree["subcommands"]): + info = tree["subcommands"][cmd] + if info["subcommands"]: + subcmds = " ".join(sorted(info["subcommands"])) + cases.append( + f" {cmd})\n" + f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n" + f" return\n" + f" ;;" + ) + elif info["flags"]: + flags = " ".join(info["flags"]) + cases.append( + f" {cmd})\n" + f" COMPREPLY=($(compgen -W \"{flags}\" -- \"$cur\"))\n" + f" return\n" + f" ;;" + ) + + cases_str = "\n".join(cases) + + return f"""# Hermes Agent bash completion +# Add to ~/.bashrc: +# eval "$(hermes completion bash)" + +_hermes_completion() {{ + local cur prev + COMPREPLY=() + cur="${{COMP_WORDS[COMP_CWORD]}}" + prev="${{COMP_WORDS[COMP_CWORD-1]}}" + + if [[ $COMP_CWORD -ge 2 ]]; then + case "${{COMP_WORDS[1]}}" in +{cases_str} + esac + fi + + if [[ $COMP_CWORD -eq 1 ]]; then + COMPREPLY=($(compgen -W "{top_cmds}" -- "$cur")) + fi +}} + +complete -F _hermes_completion hermes +""" + + +# --------------------------------------------------------------------------- +# Zsh +# --------------------------------------------------------------------------- + +def generate_zsh(parser: argparse.ArgumentParser) -> str: + tree = _walk(parser) + + top_cmds_lines: list[str] = [] + for cmd in sorted(tree["subcommands"]): + help_text = _clean(tree["subcommands"][cmd].get("help", "")) + top_cmds_lines.append(f" '{cmd}:{help_text}'") + top_cmds_str = "\n".join(top_cmds_lines) + + sub_cases: list[str] = [] + for cmd in sorted(tree["subcommands"]): + info = tree["subcommands"][cmd] + if not info["subcommands"]: + continue + sub_lines: list[str] = [] + for sc in sorted(info["subcommands"]): + sh = _clean(info["subcommands"][sc].get("help", "")) + sub_lines.append(f" '{sc}:{sh}'") + sub_str = "\n".join(sub_lines) + safe = cmd.replace("-", "_") + sub_cases.append( + f" {cmd})\n" + f" local -a {safe}_cmds\n" + f" {safe}_cmds=(\n" + f"{sub_str}\n" + f" )\n" + f" _describe '{cmd} command' {safe}_cmds\n" + f" ;;" + ) + sub_cases_str = "\n".join(sub_cases) + + return f"""#compdef hermes +# Hermes Agent zsh completion +# Add to ~/.zshrc: +# eval "$(hermes completion zsh)" + +_hermes() {{ + local context state line + typeset -A opt_args + + _arguments -C \\ + '(-h --help){{-h,--help}}[Show help and exit]' \\ + '(-V --version){{-V,--version}}[Show version and exit]' \\ + '1:command:->commands' \\ + '*::arg:->args' + + case $state in + commands) + local -a subcmds + subcmds=( +{top_cmds_str} + ) + _describe 'hermes command' subcmds + ;; + args) + case ${{line[1]}} in +{sub_cases_str} + esac + ;; + esac +}} + +_hermes "$@" +""" + + +# --------------------------------------------------------------------------- +# Fish +# --------------------------------------------------------------------------- + +def generate_fish(parser: argparse.ArgumentParser) -> str: + tree = _walk(parser) + top_cmds = sorted(tree["subcommands"]) + top_cmds_str = " ".join(top_cmds) + + lines: list[str] = [ + "# Hermes Agent fish completion", + "# Add to your config:", + "# hermes completion fish | source", + "", + "# Disable file completion by default", + "complete -c hermes -f", + "", + "# Top-level subcommands", + ] + + for cmd in top_cmds: + info = tree["subcommands"][cmd] + help_text = _clean(info.get("help", "")) + lines.append( + f"complete -c hermes -f " + f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' " + f"-a {cmd} -d '{help_text}'" + ) + + lines.append("") + lines.append("# Subcommand completions") + + for cmd in top_cmds: + info = tree["subcommands"][cmd] + if not info["subcommands"]: + continue + lines.append(f"# {cmd}") + for sc in sorted(info["subcommands"]): + sinfo = info["subcommands"][sc] + sh = _clean(sinfo.get("help", "")) + lines.append( + f"complete -c hermes -f " + f"-n '__fish_seen_subcommand_from {cmd}' " + f"-a {sc} -d '{sh}'" + ) + + lines.append("") + return "\n".join(lines) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 46a7e2c5f..955ac4028 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4124,6 +4124,8 @@ def _coalesce_session_name_args(argv: list) -> list: "status", "cron", "doctor", "config", "pairing", "skills", "tools", "mcp", "sessions", "insights", "version", "update", "uninstall", "profile", "dashboard", + "honcho", "claw", "plugins", "acp", + "webhook", "memory", "dump", "debug", "backup", "import", "completion", "logs", } _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} @@ -4422,14 +4424,24 @@ def cmd_dashboard(args): ) -def cmd_completion(args): +def cmd_completion(args, parser=None): """Print shell completion script.""" - from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion + from hermes_cli.completion import generate_bash, generate_zsh, generate_fish shell = getattr(args, "shell", "bash") - if shell == "zsh": - print(generate_zsh_completion()) + if parser is not None: + if shell == "zsh": + print(generate_zsh(parser)) + elif shell == "fish": + print(generate_fish(parser)) + else: + print(generate_bash(parser)) else: - print(generate_bash_completion()) + # Fallback: parser not available (e.g. called outside main()) + from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion + if shell == "zsh": + print(generate_zsh_completion()) + else: + print(generate_bash_completion()) def cmd_logs(args): @@ -5909,13 +5921,13 @@ Examples: # ========================================================================= completion_parser = subparsers.add_parser( "completion", - help="Print shell completion script (bash or zsh)", + help="Print shell completion script (bash, zsh, or fish)", ) completion_parser.add_argument( - "shell", nargs="?", default="bash", choices=["bash", "zsh"], + "shell", nargs="?", default="bash", choices=["bash", "zsh", "fish"], help="Shell type (default: bash)", ) - completion_parser.set_defaults(func=cmd_completion) + completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser)) # ========================================================================= # dashboard command diff --git a/tests/hermes_cli/test_completion.py b/tests/hermes_cli/test_completion.py new file mode 100644 index 000000000..78a7d01c7 --- /dev/null +++ b/tests/hermes_cli/test_completion.py @@ -0,0 +1,189 @@ +"""Tests for hermes_cli/completion.py — shell completion script generation.""" + +import argparse +import os +import re +import shutil +import subprocess +import tempfile + +import pytest + +from hermes_cli.completion import _walk, generate_bash, generate_zsh, generate_fish + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_parser() -> argparse.ArgumentParser: + """Build a minimal parser that mirrors the real hermes structure.""" + p = argparse.ArgumentParser(prog="hermes") + p.add_argument("--version", "-V", action="store_true") + sub = p.add_subparsers(dest="command") + + chat = sub.add_parser("chat", help="Interactive chat with the agent") + chat.add_argument("-q", "--query") + chat.add_argument("-m", "--model") + + gw = sub.add_parser("gateway", help="Messaging gateway management") + gw_sub = gw.add_subparsers(dest="gateway_command") + gw_sub.add_parser("start", help="Start service") + gw_sub.add_parser("stop", help="Stop service") + gw_sub.add_parser("status", help="Show status") + # alias — should NOT appear as a duplicate in completions + gw_sub.add_parser("run", aliases=["foreground"], help="Run in foreground") + + sess = sub.add_parser("sessions", help="Manage session history") + sess_sub = sess.add_subparsers(dest="sessions_action") + sess_sub.add_parser("list", help="List sessions") + sess_sub.add_parser("delete", help="Delete a session") + + sub.add_parser("version", help="Show version") + + return p + + +# --------------------------------------------------------------------------- +# 1. Parser extraction +# --------------------------------------------------------------------------- + +class TestWalk: + def test_top_level_subcommands_extracted(self): + tree = _walk(_make_parser()) + assert set(tree["subcommands"].keys()) == {"chat", "gateway", "sessions", "version"} + + def test_nested_subcommands_extracted(self): + tree = _walk(_make_parser()) + gw_subs = set(tree["subcommands"]["gateway"]["subcommands"].keys()) + assert {"start", "stop", "status", "run"}.issubset(gw_subs) + + def test_aliases_not_duplicated(self): + """'foreground' is an alias of 'run' — must not appear as separate entry.""" + tree = _walk(_make_parser()) + gw_subs = tree["subcommands"]["gateway"]["subcommands"] + assert "foreground" not in gw_subs + + def test_flags_extracted(self): + tree = _walk(_make_parser()) + chat_flags = tree["subcommands"]["chat"]["flags"] + assert "-q" in chat_flags or "--query" in chat_flags + + def test_help_text_captured(self): + tree = _walk(_make_parser()) + assert tree["subcommands"]["chat"]["help"] != "" + assert tree["subcommands"]["gateway"]["help"] != "" + + +# --------------------------------------------------------------------------- +# 2. Bash output +# --------------------------------------------------------------------------- + +class TestGenerateBash: + def test_contains_completion_function_and_register(self): + out = generate_bash(_make_parser()) + assert "_hermes_completion()" in out + assert "complete -F _hermes_completion hermes" in out + + def test_top_level_commands_present(self): + out = generate_bash(_make_parser()) + for cmd in ("chat", "gateway", "sessions", "version"): + assert cmd in out + + def test_nested_subcommands_in_case(self): + out = generate_bash(_make_parser()) + assert "start" in out + assert "stop" in out + + def test_valid_bash_syntax(self): + """Script must pass `bash -n` syntax check.""" + out = generate_bash(_make_parser()) + with tempfile.NamedTemporaryFile(mode="w", suffix=".bash", delete=False) as f: + f.write(out) + path = f.name + try: + result = subprocess.run(["bash", "-n", path], capture_output=True) + assert result.returncode == 0, result.stderr.decode() + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# 3. Zsh output +# --------------------------------------------------------------------------- + +class TestGenerateZsh: + def test_contains_compdef_header(self): + out = generate_zsh(_make_parser()) + assert "#compdef hermes" in out + + def test_top_level_commands_present(self): + out = generate_zsh(_make_parser()) + for cmd in ("chat", "gateway", "sessions", "version"): + assert cmd in out + + def test_nested_describe_blocks(self): + out = generate_zsh(_make_parser()) + assert "_describe" in out + # gateway has subcommands so a _cmds array must be generated + assert "gateway_cmds" in out + + +# --------------------------------------------------------------------------- +# 4. Fish output +# --------------------------------------------------------------------------- + +class TestGenerateFish: + def test_disables_file_completion(self): + out = generate_fish(_make_parser()) + assert "complete -c hermes -f" in out + + def test_top_level_commands_present(self): + out = generate_fish(_make_parser()) + for cmd in ("chat", "gateway", "sessions", "version"): + assert cmd in out + + def test_subcommand_guard_present(self): + out = generate_fish(_make_parser()) + assert "__fish_seen_subcommand_from" in out + + def test_valid_fish_syntax(self): + """Script must be accepted by fish without errors.""" + if not shutil.which("fish"): + pytest.skip("fish not installed") + out = generate_fish(_make_parser()) + with tempfile.NamedTemporaryFile(mode="w", suffix=".fish", delete=False) as f: + f.write(out) + path = f.name + try: + result = subprocess.run(["fish", path], capture_output=True) + assert result.returncode == 0, result.stderr.decode() + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# 5. Subcommand drift prevention +# --------------------------------------------------------------------------- + +class TestSubcommandDrift: + def test_SUBCOMMANDS_covers_required_commands(self): + """_SUBCOMMANDS must include all known top-level commands so that + multi-word session names after -c/-r are never accidentally split. + """ + import inspect + from hermes_cli.main import _coalesce_session_name_args + + source = inspect.getsource(_coalesce_session_name_args) + match = re.search(r'_SUBCOMMANDS\s*=\s*\{([^}]+)\}', source, re.DOTALL) + assert match, "_SUBCOMMANDS block not found in _coalesce_session_name_args()" + defined = set(re.findall(r'"(\w+)"', match.group(1))) + + required = { + "chat", "model", "gateway", "setup", "login", "logout", "auth", + "status", "cron", "config", "sessions", "version", "update", + "uninstall", "profile", "skills", "tools", "mcp", "plugins", + "acp", "claw", "honcho", "completion", "logs", + } + missing = required - defined + assert not missing, f"Missing from _SUBCOMMANDS: {missing}"