{"repo":"NousResearch/hermes-agent","public_id":"2582","display_label":"Add shell completion support for CLI","summary":"These items all concern adding shell completion functionality to the CLI, supporting bash, zsh, and fish shells. The work includes both static and dynamic completion approaches via a new completion subcommand.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":9785},"resolution":{"state":"resolved","by":"pr-9785"},"freshness":{"state":"stale","last_verified_at":"2026-04-14T17:45:42+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":2582,"pr_number":9785,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2582,"title":"feat: add shell completion support (hermes completion zsh|bash)","url":null,"state":"closed"},{"kind":"pr","number":2669,"title":"feat(cli): add shell completion support (hermes completion zsh|bash)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":9360,"title":"feat(completion): add dynamic shell completion for bash, zsh, and fish","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":9476,"title":"feat(cli): add dynamic shell completion for bash, zsh, and fish","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9785","number":9785}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":9785,"title":"feat(cli): add dynamic shell completion for bash, zsh, and fish","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/completion.py","hermes_cli/main.py","scripts/install.sh","tests/hermes_cli/test_completion.py"],"fix_hunks":[{"file":"hermes_cli/completion.py","hunks":["@@ -0,0 +1,315 @@\n+\"\"\"Shell completion script generation for hermes CLI.\n+\n+Walks the live argparse parser tree to generate accurate, always-up-to-date\n+completion scripts — no hardcoded subcommand lists, no extra dependencies.\n+\n+Supports bash, zsh, and fish.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import argparse\n+from typing import Any\n+\n+\n+def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]:\n+    \"\"\"Recursively extract subcommands and flags from a parser.\n+\n+    Uses _SubParsersAction._choices_actions to get canonical names (no aliases)\n+    along with their help text.\n+    \"\"\"\n+    flags: list[str] = []\n+    subcommands: dict[str, Any] = {}\n+\n+    for action in parser._actions:\n+        if isinstance(action, argparse._SubParsersAction):\n+            # _choices_actions has one entry per canonical name; aliases are\n+            # omitted, which keeps completion lists clean.\n+            seen: set[str] = set()\n+            for pseudo in action._choices_actions:\n+                name = pseudo.dest\n+                if name in seen:\n+                    continue\n+                seen.add(name)\n+                subparser = action.choices.get(name)\n+                if subparser is None:\n+                    continue\n+                info = _walk(subparser)\n+                info[\"help\"] = _clean(pseudo.help or \"\")\n+                subcommands[name] = info\n+        elif action.option_strings:\n+            flags.extend(o for o in action.option_strings if o.startswith(\"-\"))\n+\n+    return {\"flags\": flags, \"subcommands\": subcommands}\n+\n+\n+def _clean(text: str, maxlen: int = 60) -> str:\n+    \"\"\"Strip shell-unsafe characters and truncate.\"\"\"\n+    return text.replace(\"'\", \"\").replace('\"', \"\").replace(\"\\\\\", \"\")[:maxlen]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bash\n+# ---------------------------------------------------------------------------\n+\n+def generate_bash(parser: argparse.ArgumentParser) -> str:\n+    tree = _walk(parser)\n+    top_cmds = \" \".join(sorted(tree[\"subcommands\"]))\n+\n+    cases: list[str] = []\n+    for cmd in sorted(tree[\"subcommands\"]):\n+        info = tree[\"subcommands\"][cmd]\n+        if cmd == \"profile\" and info[\"subcommands\"]:\n+            # Profile subcommand: complete actions, then profile names for\n+            # actions that accept a profile argument.\n+            subcmds = \" \".join(sorted(info[\"subcommands\"]))\n+            profile_actions = \"use delete show alias rename export\"\n+            cases.append(\n+                f\"        profile)\\n\"\n+                f\"            case \\\"$prev\\\" in\\n\"\n+                f\"                profile)\\n\"\n+                f\"                    COMPREPLY=($(compgen -W \\\"{subcmds}\\\" -- \\\"$cur\\\"))\\n\"\n+                f\"                    return\\n\"\n+                f\"                    ;;\\n\"\n+                f\"                {profile_actions.replace(' ', '|')})\\n\"\n+                f\"                    COMPREPLY=($(compgen -W \\\"$(_hermes_profiles)\\\" -- \\\"$cur\\\"))\\n\"\n+                f\"                    return\\n\"\n+                f\"                    ;;\\n\"\n+                f\"            esac\\n\"\n+                f\"            ;;\"\n+            )\n+        elif info[\"subcommands\"]:\n+            subcmds = \" \".join(sorted(info[\"subcommands\"]))\n+            cases.append(\n+                f\"        {cmd})\\n\"\n+                f\"            COMPREPLY=($(compgen -W \\\"{subcmds}\\\" -- \\\"$cur\\\"))\\n\"\n+                f\"            return\\n\"\n+                f\"            ;;\"\n+            )\n+        elif info[\"flags\"]:\n+            flags = \" \".join(info[\"flags\"])\n+            cases.append(\n+                f\"        {cmd})\\n\"\n+                f\"            COMPREPLY=($(compgen -W \\\"{flags}\\\" -- \\\"$cur\\\"))\\n\"\n+                f\"            return\\n\"\n+                f\"            ;;\"\n+            )\n+\n+    cases_str = \"\\n\".join(cases)\n+\n+    return f\"\"\"# Hermes Agent bash completion\n+# Add to ~/.bashrc:\n+#   eval \"$(hermes completion bash)\"\n+\n+_hermes_profiles() {{\n+    local profiles_dir=\"$HOME/.hermes/profiles\"\n+    local profiles=\"default\"\n+    if [ -d \"$profiles_dir\" ]; then\n+        profiles=\"$profiles $(ls \"$profiles_dir\" 2>/dev/null)\"\n+    fi\n+    echo \"$profiles\"\n+}}\n+\n+_hermes_completion() {{\n+    local cur prev\n+    COMPREPLY=()\n+    cur=\"${{COMP_WORDS[COMP_CWORD]}}\"\n+    prev=\"${{COMP_WORDS[COMP_CWORD-1]}}\"\n+\n+    # Complete profile names after -p / --profile\n+    if [[ \"$prev\" == \"-p\" || \"$prev\" == \"--profile\" ]]; then\n+        COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n+        return\n+    fi\n+\n+    if [[ $COMP_CWORD -ge 2 ]]; then\n+        case \"${{COMP_WORDS[1]}}\" in\n+{cases_str}\n+        esac\n+    fi\n+\n+    if [[ $COMP_CWORD -eq 1 ]]; then\n+        COMPREPLY=($(compgen -W \"{top_cmds}\" -- \"$cur\"))\n+    fi\n+}}\n+\n+complete -F _hermes_completion hermes\n+\"\"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Zsh\n+# ---------------------------------------------------------------------------\n+\n+def generate_zsh(parser: argparse.ArgumentParser) -> str:\n+    tree = _walk(parser)\n+\n+    top_cmds_lines: list[str] = []\n+    for cmd in sorted(tree[\"subcommands\"]):\n+        help_text = _clean(tree[\"subcommands\"][cmd].get(\"help\", \"\"))\n+        top_cmds_lines.append(f\"                '{cmd}:{help_text}'\")\n+    top_cmds_str = \"\\n\".join(top_cmds_lines)\n+\n+    sub_cases: list[str] = []\n+    for cmd in sorted(tree[\"subcommands\"]):\n+        info = tree[\"subcommands\"][cmd]\n+        if not info[\"subcommands\"]:\n+            continue\n+        if cmd == \"profile\":\n+            # Profile subcommand: complete actions, then profile names for\n+            # actions that accept a profile argument.\n+            sub_lines: list[str] = []\n+            for sc in sorted(info[\"subcommands\"]):\n+                sh = _clean(info[\"subcommands\"][sc].get(\"help\", \"\"))\n+                sub_lines.append(f\"                        '{sc}:{sh}'\")\n+            sub_str = \"\\n\".join(sub_lines)\n+            sub_cases.append(\n+                f\"                profile)\\n\"\n+                f\"                    case ${{line[2]}} in\\n\"\n+                f\"                        use|delete|show|alias|rename|export)\\n\"\n+                f\"                            _hermes_profiles\\n\"\n+                f\"                            ;;\\n\"\n+                f\"                        *)\\n\"\n+                f\"                            local -a profile_cmds\\n\"\n+                f\"                            profile_cmds=(\\n\"\n+                f\"{sub_str}\\n\"\n+                f\"                            )\\n\"\n+                f\"                            _describe 'profile command' profile_cmds\\n\"\n+                f\"                            ;;\\n\"\n+                f\"                    esac\\n\"\n+                f\"                    ;;\"\n+            )\n+        else:\n+            sub_lines = []\n+            for sc in sorted(info[\"subcommands\"]):\n+                sh = _clean(info[\"subcommands\"][sc].get(\"help\", \"\"))\n+                sub_lines.append(f\"                    '{sc}:{sh}'\")\n+            sub_str = \"\\n\".join(sub_lines)\n+            safe = cmd.replace(\"-\", \"_\")\n+            sub_cases.append(\n+                f\"                {cmd})\\n\"\n+                f\"                    local -a {safe}_cmds\\n\"\n+                f\"                    {safe}_cmds=(\\n\"\n+                f\"{sub_str}\\n\"\n+                f\"                    )\\n\"\n+                f\"                    _describe '{cmd} command' {safe}_cmds\\n\"\n+                f\"                    ;;\"\n+            )\n+    sub_cases_str = \"\\n\".join(sub_cases)\n+\n+    return f\"\"\"#compdef hermes\n+# Hermes Agent zsh completion\n+# Add to ~/.zshrc:\n+#   eval \"$(hermes completion zsh)\"\n+\n+_hermes_profiles() {{\n+    local -a profiles\n+    profiles=(default)\n+    if [[ -d \"$HOME/.hermes/profiles\" ]]; then\n+        profiles+=(\"${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}\")\n+    fi\n+    _describe 'profile' profiles\n+}}\n+\n+_hermes() {{\n+    local context state line\n+    typeset -A opt_args\n+\n+    _arguments -C \\\\\n+        '(-h --help){{-h,--help}}[Show help and exit]' \\\\\n+        '(-V --version){{-V,--version}}[Show version and exit]' \\\\\n+        '(-p --profile){{-p,--profile}}[Profile name]:profile:_hermes_profiles' \\\\\n+        '1:command:->commands' \\\\\n+        '*::arg:->args'\n+\n+    case $state in\n+        commands)\n+            local -a subcmds\n+            subcmds=(\n+{top_cmds_str}\n+            )\n+            _describe 'hermes command' subcmds\n+            ;;\n+        args)\n+            case ${{line[1]}} in\n+{sub_cases_str}\n+            esac\n+            ;;\n+    esac\n+}}\n+\n+_hermes \"$@\"\n+\"\"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fish\n+# ---------------------------------------------------------------------------\n+\n+def generate_fish(parser: argparse.ArgumentParser) -> str:\n+    tree = _walk(parser)\n+    top_cmds = sorted(tree[\"subcommands\"])\n+    top_cmds_str = \" \".join(top_cmds)\n+\n+    lines: list[str] = [\n+        \"# Hermes Agent fish completion\",\n+        \"# Add to your config:\",\n+        \"#   hermes completion fish | source\",\n+        \"\",\n+        \"# Helper: list available profiles\",\n+        \"function __hermes_profiles\",\n+        \"    echo default\",\n+        \"    if test -d $HOME/.hermes/profiles\",\n+        \"        ls $HOME/.hermes/profiles 2>/dev/null\",\n+        \"    end\",\n+        \"end\",\n+        \"\",\n+        \"# Disable file completion by default\",\n+        \"complete -c hermes -f\",\n+        \"\",\n+        \"# Complete profile names after -p / --profile\",\n+        \"complete -c hermes -f -s p -l profile\"\n+        \" -d 'Profile name' -xa '(__hermes_profiles)'\",\n+        \"\",\n+        \"# Top-level subcommands\",\n+    ]\n+\n+    for cmd in top_cmds:\n+        info = tree[\"subcommands\"][cmd]\n+        help_text = _clean(info.get(\"help\", \"\"))\n+        lines.append(\n+            f\"complete -c hermes -f \"\n+            f\"-n 'not __fish_seen_subcommand_from {top_cmds_str}' \"\n+            f\"-a {cmd} -d '{help_text}'\"\n+        )\n+\n+    lines.append(\"\")\n+    lines.append(\"# Subcommand completions\")\n+\n+    profile_name_actions = {\"use\", \"delete\", \"show\", \"alias\", \"rename\", \"export\"}\n+\n+    for cmd in top_cmds:\n+        info = tree[\"subcommands\"][cmd]\n+        if not info[\"subcommands\"]:\n+            continue\n+        lines.append(f\"# {cmd}\")\n+        for sc in sorted(info[\"subcommands\"]):\n+            sinfo = info[\"subcommands\"][sc]\n+            sh = _clean(sinfo.get(\"help\", \"\"))\n+            lines.append(\n+                f\"complete -c hermes -f \"\n+                f\"-n '__fish_seen_subcommand_from {cmd}' \"\n+                f\"-a {sc} -d '{sh}'\"\n+            )\n+        # For profile subcommand, complete profile names for relevant actions\n+        if cmd == \"profile\":\n+            for action in sorted(profile_name_actions):\n+                lines.append(\n+                    f\"complete -c hermes -f \"\n+                    f\"-n '__fish_seen_subcommand_from {action}; \"\n+                    f\"and __fish_seen_subcommand_from profile' \"\n+                    f\"-a '(__hermes_profiles)' -d 'Profile name'\"\n+                )\n+\n+    lines.append(\"\")\n+    return \"\\n\".join(lines)"]},{"file":"hermes_cli/main.py","hunks":["@@ -4124,6 +4124,8 @@ def _coalesce_session_name_args(argv: list) -> list:\n         \"status\", \"cron\", \"doctor\", \"config\", \"pairing\", \"skills\", \"tools\",\n         \"mcp\", \"sessions\", \"insights\", \"version\", \"update\", \"uninstall\",\n         \"profile\", \"dashboard\",\n+        \"honcho\", \"claw\", \"plugins\", \"acp\",\n+        \"webhook\", \"memory\", \"dump\", \"debug\", \"backup\", \"import\", \"completion\", \"logs\",\n     }\n     _SESSION_FLAGS = {\"-c\", \"--continue\", \"-r\", \"--resume\"}\n ","@@ -4422,14 +4424,16 @@ def cmd_dashboard(args):\n     )\n \n \n-def cmd_completion(args):\n+def cmd_completion(args, parser=None):\n     \"\"\"Print shell completion script.\"\"\"\n-    from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion\n+    from hermes_cli.completion import generate_bash, generate_zsh, generate_fish\n     shell = getattr(args, \"shell\", \"bash\")\n     if shell == \"zsh\":\n-        print(generate_zsh_completion())\n+        print(generate_zsh(parser))\n+    elif shell == \"fish\":\n+        print(generate_fish(parser))\n     else:\n-        print(generate_bash_completion())\n+        print(generate_bash(parser))\n \n \n def cmd_logs(args):","@@ -5909,13 +5913,13 @@ def cmd_acp(args):\n     # =========================================================================\n     completion_parser = subparsers.add_parser(\n         \"completion\",\n-        help=\"Print shell completion script (bash or zsh)\",\n+        help=\"Print shell completion script (bash, zsh, or fish)\",\n     )\n     completion_parser.add_argument(\n-        \"shell\", nargs=\"?\", default=\"bash\", choices=[\"bash\", \"zsh\"],\n+        \"shell\", nargs=\"?\", default=\"bash\", choices=[\"bash\", \"zsh\", \"fish\"],\n         help=\"Shell type (default: bash)\",\n     )\n-    completion_parser.set_defaults(func=cmd_completion)\n+    completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser))\n \n     # =========================================================================\n     # dashboard command"]},{"file":"scripts/install.sh","hunks":["@@ -945,6 +945,7 @@ setup_path() {\n     # which is always bash when piped from curl).\n     if ! echo \"$PATH\" | tr ':' '\\n' | grep -q \"^$command_link_dir$\"; then\n         SHELL_CONFIGS=()\n+        IS_FISH=false\n         LOGIN_SHELL=\"$(basename \"${SHELL:-/bin/bash}\")\"\n         case \"$LOGIN_SHELL\" in\n             zsh)","@@ -960,6 +961,13 @@ setup_path() {\n                 [ -f \"$HOME/.bashrc\" ] && SHELL_CONFIGS+=(\"$HOME/.bashrc\")\n                 [ -f \"$HOME/.bash_profile\" ] && SHELL_CONFIGS+=(\"$HOME/.bash_profile\")\n                 ;;\n+            fish)\n+                # fish uses ~/.config/fish/config.fish and fish_add_path — not export PATH=\n+                IS_FISH=true\n+                FISH_CONFIG=\"$HOME/.config/fish/config.fish\"\n+                mkdir -p \"$(dirname \"$FISH_CONFIG\")\"\n+                touch \"$FISH_CONFIG\"\n+                ;;\n             *)\n                 [ -f \"$HOME/.bashrc\" ] && SHELL_CONFIGS+=(\"$HOME/.bashrc\")\n                 [ -f \"$HOME/.zshrc\" ] && SHELL_CONFIGS+=(\"$HOME/.zshrc\")","@@ -967,7 +975,7 @@ setup_path() {\n         esac\n         # Also ensure ~/.profile has it (sourced by login shells on\n         # Ubuntu/Debian/WSL even when ~/.bashrc is skipped)\n-        [ -f \"$HOME/.profile\" ] && SHELL_CONFIGS+=(\"$HOME/.profile\")\n+        [ \"$IS_FISH\" = \"false\" ] && [ -f \"$HOME/.profile\" ] && SHELL_CONFIGS+=(\"$HOME/.profile\")\n \n         PATH_LINE='export PATH=\"$HOME/.local/bin:$PATH\"'\n ","@@ -980,7 +988,17 @@ setup_path() {\n             fi\n         done\n \n-        if [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then\n+        # fish uses fish_add_path instead of export PATH=...\n+        if [ \"$IS_FISH\" = \"true\" ]; then\n+            if ! grep -q 'fish_add_path.*\\.local/bin' \"$FISH_CONFIG\" 2>/dev/null; then\n+                echo \"\" >> \"$FISH_CONFIG\"\n+                echo \"# Hermes Agent — ensure ~/.local/bin is on PATH\" >> \"$FISH_CONFIG\"\n+                echo 'fish_add_path \"$HOME/.local/bin\"' >> \"$FISH_CONFIG\"\n+                log_success \"Added ~/.local/bin to PATH in $FISH_CONFIG\"\n+            fi\n+        fi\n+\n+        if [ \"$IS_FISH\" = \"false\" ] && [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then\n             log_warn \"Could not detect shell config file to add ~/.local/bin to PATH\"\n             log_info \"Add manually: $PATH_LINE\"\n         fi","@@ -1315,6 +1333,8 @@ print_success() {\n             echo \"   source ~/.zshrc\"\n         elif [ \"$LOGIN_SHELL\" = \"bash\" ]; then\n             echo \"   source ~/.bashrc\"\n+        elif [ \"$LOGIN_SHELL\" = \"fish\" ]; then\n+            echo \"   source ~/.config/fish/config.fish\"\n         else\n             echo \"   source ~/.bashrc   # or ~/.zshrc\"\n         fi"]},{"file":"tests/hermes_cli/test_completion.py","hunks":["@@ -0,0 +1,271 @@\n+\"\"\"Tests for hermes_cli/completion.py — shell completion script generation.\"\"\"\n+\n+import argparse\n+import os\n+import re\n+import shutil\n+import subprocess\n+import tempfile\n+\n+import pytest\n+\n+from hermes_cli.completion import _walk, generate_bash, generate_zsh, generate_fish\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_parser() -> argparse.ArgumentParser:\n+    \"\"\"Build a minimal parser that mirrors the real hermes structure.\"\"\"\n+    p = argparse.ArgumentParser(prog=\"hermes\")\n+    p.add_argument(\"--version\", \"-V\", action=\"store_true\")\n+    p.add_argument(\"-p\", \"--profile\", help=\"Profile name\")\n+    sub = p.add_subparsers(dest=\"command\")\n+\n+    chat = sub.add_parser(\"chat\", help=\"Interactive chat with the agent\")\n+    chat.add_argument(\"-q\", \"--query\")\n+    chat.add_argument(\"-m\", \"--model\")\n+\n+    gw = sub.add_parser(\"gateway\", help=\"Messaging gateway management\")\n+    gw_sub = gw.add_subparsers(dest=\"gateway_command\")\n+    gw_sub.add_parser(\"start\", help=\"Start service\")\n+    gw_sub.add_parser(\"stop\", help=\"Stop service\")\n+    gw_sub.add_parser(\"status\", help=\"Show status\")\n+    # alias — should NOT appear as a duplicate in completions\n+    gw_sub.add_parser(\"run\", aliases=[\"foreground\"], help=\"Run in foreground\")\n+\n+    sess = sub.add_parser(\"sessions\", help=\"Manage session history\")\n+    sess_sub = sess.add_subparsers(dest=\"sessions_action\")\n+    sess_sub.add_parser(\"list\", help=\"List sessions\")\n+    sess_sub.add_parser(\"delete\", help=\"Delete a session\")\n+\n+    prof = sub.add_parser(\"profile\", help=\"Manage profiles\")\n+    prof_sub = prof.add_subparsers(dest=\"profile_command\")\n+    prof_sub.add_parser(\"list\", help=\"List profiles\")\n+    prof_sub.add_parser(\"use\", help=\"Switch to a profile\")\n+    prof_sub.add_parser(\"create\", help=\"Create a new profile\")\n+    prof_sub.add_parser(\"delete\", help=\"Delete a profile\")\n+    prof_sub.add_parser(\"show\", help=\"Show profile details\")\n+    prof_sub.add_parser(\"alias\", help=\"Set profile alias\")\n+    prof_sub.add_parser(\"rename\", help=\"Rename a profile\")\n+    prof_sub.add_parser(\"export\", help=\"Export a profile\")\n+\n+    sub.add_parser(\"version\", help=\"Show version\")\n+\n+    return p\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1. Parser extraction\n+# ---------------------------------------------------------------------------\n+\n+class TestWalk:\n+    def test_top_level_subcommands_extracted(self):\n+        tree = _walk(_make_parser())\n+        assert set(tree[\"subcommands\"].keys()) == {\"chat\", \"gateway\", \"sessions\", \"profile\", \"version\"}\n+\n+    def test_nested_subcommands_extracted(self):\n+        tree = _walk(_make_parser())\n+        gw_subs = set(tree[\"subcommands\"][\"gateway\"][\"subcommands\"].keys())\n+        assert {\"start\", \"stop\", \"status\", \"run\"}.issubset(gw_subs)\n+\n+    def test_aliases_not_duplicated(self):\n+        \"\"\"'foreground' is an alias of 'run' — must not appear as separate entry.\"\"\"\n+        tree = _walk(_make_parser())\n+        gw_subs = tree[\"subcommands\"][\"gateway\"][\"subcommands\"]\n+        assert \"foreground\" not in gw_subs\n+\n+    def test_flags_extracted(self):\n+        tree = _walk(_make_parser())\n+        chat_flags = tree[\"subcommands\"][\"chat\"][\"flags\"]\n+        assert \"-q\" in chat_flags or \"--query\" in chat_flags\n+\n+    def test_help_text_captured(self):\n+        tree = _walk(_make_parser())\n+        assert tree[\"subcommands\"][\"chat\"][\"help\"] != \"\"\n+        assert tree[\"subcommands\"][\"gateway\"][\"help\"] != \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# 2. Bash output\n+# ---------------------------------------------------------------------------\n+\n+class TestGenerateBash:\n+    def test_contains_completion_function_and_register(self):\n+        out = generate_bash(_make_parser())\n+        assert \"_hermes_completion()\" in out\n+        assert \"complete -F _hermes_completion hermes\" in out\n+\n+    def test_top_level_commands_present(self):\n+        out = generate_bash(_make_parser())\n+        for cmd in (\"chat\", \"gateway\", \"sessions\", \"version\"):\n+            assert cmd in out\n+\n+    def test_nested_subcommands_in_case(self):\n+        out = generate_bash(_make_parser())\n+        assert \"start\" in out\n+        assert \"stop\" in out\n+\n+    def test_valid_bash_syntax(self):\n+        \"\"\"Script must pass `bash -n` syntax check.\"\"\"\n+        out = generate_bash(_make_parser())\n+        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".bash\", delete=False) as f:\n+            f.write(out)\n+            path = f.name\n+        try:\n+            result = subprocess.run([\"bash\", \"-n\", path], capture_output=True)\n+            assert result.returncode == 0, result.stderr.decode()\n+        finally:\n+            os.unlink(path)\n+\n+\n+# ---------------------------------------------------------------------------\n+# 3. Zsh output\n+# ---------------------------------------------------------------------------\n+\n+class TestGenerateZsh:\n+    def test_contains_compdef_header(self):\n+        out = generate_zsh(_make_parser())\n+        assert \"#compdef hermes\" in out\n+\n+    def test_top_level_commands_present(self):\n+        out = generate_zsh(_make_parser())\n+        for cmd in (\"chat\", \"gateway\", \"sessions\", \"version\"):\n+            assert cmd in out\n+\n+    def test_nested_describe_blocks(self):\n+        out = generate_zsh(_make_parser())\n+        assert \"_describe\" in out\n+        # gateway has subcommands so a _cmds array must be generated\n+        assert \"gateway_cmds\" in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# 4. Fish output\n+# ---------------------------------------------------------------------------\n+\n+class TestGenerateFish:\n+    def test_disables_file_completion(self):\n+        out = generate_fish(_make_parser())\n+        assert \"complete -c hermes -f\" in out\n+\n+    def test_top_level_commands_present(self):\n+        out = generate_fish(_make_parser())\n+        for cmd in (\"chat\", \"gateway\", \"sessions\", \"version\"):\n+            assert cmd in out\n+\n+    def test_subcommand_guard_present(self):\n+        out = generate_fish(_make_parser())\n+        assert \"__fish_seen_subcommand_from\" in out\n+\n+    def test_valid_fish_syntax(self):\n+        \"\"\"Script must be accepted by fish without errors.\"\"\"\n+        if not shutil.which(\"fish\"):\n+            pytest.skip(\"fish not installed\")\n+        out = generate_fish(_make_parser())\n+        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".fish\", delete=False) as f:\n+            f.write(out)\n+            path = f.name\n+        try:\n+            result = subprocess.run([\"fish\", path], capture_output=True)\n+            assert result.returncode == 0, result.stderr.decode()\n+        finally:\n+            os.unlink(path)\n+\n+\n+# ---------------------------------------------------------------------------\n+# 5. Subcommand drift prevention\n+# ---------------------------------------------------------------------------\n+\n+class TestSubcommandDrift:\n+    def test_SUBCOMMANDS_covers_required_commands(self):\n+        \"\"\"_SUBCOMMANDS must include all known top-level commands so that\n+        multi-word session names after -c/-r are never accidentally split.\n+        \"\"\"\n+        import inspect\n+        from hermes_cli.main import _coalesce_session_name_args\n+\n+        source = inspect.getsource(_coalesce_session_name_args)\n+        match = re.search(r'_SUBCOMMANDS\\s*=\\s*\\{([^}]+)\\}', source, re.DOTALL)\n+        assert match, \"_SUBCOMMANDS block not found in _coalesce_session_name_args()\"\n+        defined = set(re.findall(r'\"(\\w+)\"', match.group(1)))\n+\n+        required = {\n+            \"chat\", \"model\", \"gateway\", \"setup\", \"login\", \"logout\", \"auth\",\n+            \"status\", \"cron\", \"config\", \"sessions\", \"version\", \"update\",\n+            \"uninstall\", \"profile\", \"skills\", \"tools\", \"mcp\", \"plugins\",\n+            \"acp\", \"claw\", \"honcho\", \"completion\", \"logs\",\n+        }\n+        missing = required - defined\n+        assert not missing, f\"Missing from _SUBCOMMANDS: {missing}\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# 6. Profile completion (regression prevention)\n+# ---------------------------------------------------------------------------\n+\n+class TestProfileCompletion:\n+    \"\"\"Ensure profile name completion is present in all shell outputs.\"\"\"\n+\n+    def test_bash_has_profiles_helper(self):\n+        out = generate_bash(_make_parser())\n+        assert \"_hermes_profiles()\" in out\n+        assert 'profiles_dir=\"$HOME/.hermes/profiles\"' in out\n+\n+    def test_bash_completes_profiles_after_p_flag(self):\n+        out = generate_bash(_make_parser())\n+        assert '\"-p\"' in out or \"== \\\"-p\\\"\" in out\n+        assert '\"--profile\"' in out or '== \"--profile\"' in out\n+        assert \"_hermes_profiles\" in out\n+\n+    def test_bash_profile_subcommand_has_action_completion(self):\n+        out = generate_bash(_make_parser())\n+        assert \"use|delete|show|alias|rename|export)\" in out\n+\n+    def test_bash_profile_actions_complete_profile_names(self):\n+        \"\"\"After 'hermes profile use', complete with profile names.\"\"\"\n+        out = generate_bash(_make_parser())\n+        # The profile case should have _hermes_profiles for name-taking actions\n+        lines = out.split(\"\\n\")\n+        in_profile_case = False\n+        has_profiles_in_action = False\n+        for line in lines:\n+            if \"profile)\" in line:\n+                in_profile_case = True\n+            if in_profile_case and \"_hermes_profiles\" in line:\n+                has_profiles_in_action = True\n+                break\n+        assert has_profiles_in_action, \"profile actions should complete with _hermes_profiles\"\n+\n+    def test_zsh_has_profiles_helper(self):\n+        out = generate_zsh(_make_parser())\n+        assert \"_hermes_profiles()\" in out\n+        assert \"$HOME/.hermes/profiles\" in out\n+\n+    def test_zsh_has_profile_flag_completion(self):\n+        out = generate_zsh(_make_parser())\n+        assert \"--profile\" in out\n+        assert \"_hermes_profiles\" in out\n+\n+    def test_zsh_profile_actions_complete_names(self):\n+        out = generate_zsh(_make_parser())\n+        assert \"use|delete|show|alias|rename|export)\" in out\n+\n+    def test_fish_has_profiles_helper(self):\n+        out = generate_fish(_make_parser())\n+        assert \"__hermes_profiles\" in out\n+        assert \"$HOME/.hermes/profiles\" in out\n+\n+    def test_fish_has_profile_flag_completion(self):\n+        out = generate_fish(_make_parser())\n+        assert \"-s p -l profile\" in out\n+        assert \"(__hermes_profiles)\" in out\n+\n+    def test_fish_profile_actions_complete_names(self):\n+        out = generate_fish(_make_parser())\n+        # Should have profile name completion for actions like use, delete, etc.\n+        assert \"__hermes_profiles\" in out\n+        count = out.count(\"(__hermes_profiles)\")\n+        # At least the -p flag + the profile action completions\n+        assert count >= 2, f\"Expected >=2 profile completion entries, got {count}\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}