# NousResearch/hermes-agent · Add shell completion support for CLI

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #9785. Verify it addresses your case.
- **Recommended:** read PR #9785 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-9785`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2582: feat: add shell completion support (hermes completion zsh|bash)

## Prior & competing PRs

- **#2669: feat(cli): add shell completion support (hermes completion zsh|bash)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#9360: feat(completion): add dynamic shell completion for bash, zsh, and fish**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#9785: feat(cli): add dynamic shell completion for bash, zsh, and fish**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/completion.py`, `hermes_cli/main.py`, `scripts/install.sh`, `tests/hermes_cli/test_completion.py`
  - fix hunks:
  - `hermes_cli/completion.py`

    ```diff
    @@ -0,0 +1,315 @@
    +"""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 cmd == "profile" and info["subcommands"]:
    +            # Profile subcommand: complete actions, then profile names for
    +            # actions that accept a profile argument.
    +            subcmds = " ".join(sorted(info["subcommands"]))
    +            profile_actions = "use delete show alias rename export"
    +            cases.append(
    +                f"        profile)\n"
    +                f"            case \"$prev\" in\n"
    +                f"                profile)\n"
    +                f"                    COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
    +                f"                    return\n"
    +                f"                    ;;\n"
    +                f"                {profile_actions.replace(' ', '|')})\n"
    +                f"                    COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n"
    +                f"                    return\n"
    +                f"                    ;;\n"
    +                f"            esac\n"
    +                f"            ;;"
    +            )
    +        elif 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_profiles() {{
    +    local profiles_dir="$HOME/.hermes/profiles"
    +    local profiles="default"
    +    if [ -d "$profiles_dir" ]; then
    +        profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
    +    fi
    +    echo "$profiles"
    +}}
    +
    +_hermes_completion() {{
    +    local cur prev
    +    COMPREPLY=()
    +    cur="${{COMP_WORDS[COMP_CWORD]}}"
    +    prev="${{COMP_WORDS[COMP_CWORD-1]}}"
    +
    +    # Complete profile names after -p / --profile
    +    if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then
    +        COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
    +        return
    +    fi
    +
    +    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
    +        if cmd == "profile":
    +            # Profile subcommand: complete actions, then profile names for
    +            # actions that accept a profile argument.
    +            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)
    +            sub_cases.append(
    +                f"                profile)\n"
    +                f"                    case ${{line[2]}} in\n"
    +                f"                        use|delete|show|alias|rename|export)\n"
    +                f"                            _hermes_profiles\n"
    +                f"                            ;;\n"
    +                f"                        *)\n"
    +                f"                            local -a profile_cmds\n"
    +                f"                            profile_cmds=(\n"
    +                f"{sub_str}\n"
    +                f"                            )\n"
    +                f"                            _describe 'profile command' profile_cmds\n"
    +                f"                            ;;\n"
    +                f"                    esac\n"
    +                f"                    ;;"
    +            )
    +        else:
    +            sub_lines = []
    +            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_profiles() {{
    +    local -a profiles
    +    profiles=(default)
    +    if [[ -d "$HOME/.hermes/profiles" ]]; then
    +        profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
    +    fi
    +    _describe 'profile' profiles
    +}}
    +
    +_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]' \\
    +        '(-p --profile){{-p,--profile}}[Profile name]:profile:_hermes_profiles' \\
    +        '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",
    +        "",
    +        "# Helper: list available profiles",
    +        "function __hermes_profiles",
    +        "    echo default",
    +        "    if test -d $HOME/.hermes/profiles",
    +        "        ls $HOME/.hermes/profiles 2>/dev/null",
    +        "    end",
    +        "end",
    +        "",
    +        "# Disable file completion by default",
    +        "complete -c hermes -f",
    +        "",
    +        "# Complete profile names after -p / --profile",
    +        "complete -c hermes -f -s p -l profile"
    +        " -d 'Profile name' -xa '(__hermes_profiles)'",
    +        "",
    +        "# 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")
    +
    +    profile_name_actions = {"use", "delete", "show", "alias", "rename", "export"}
    +
    +    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}'"
    +            )
    +        # For profile subcommand, complete profile names for relevant actions
    +        if cmd == "profile":
    +            for action in sorted(profile_name_actions):
    +                lines.append(
    +                    f"complete -c hermes -f "
    +                    f"-n '__fish_seen_subcommand_from {action}; "
    +                    f"and __fish_seen_subcommand_from profile' "
    +                    f"-a '(__hermes_profiles)' -d 'Profile name'"
    +                )
    +
    +    lines.append("")
    +    return "\n".join(lines)
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -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"}
     
    ```

    ```diff
    @@ -4422,14 +4424,16 @@ 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())
    +        print(generate_zsh(parser))
    +    elif shell == "fish":
    +        print(generate_fish(parser))
         else:
    -        print(generate_bash_completion())
    +        print(generate_bash(parser))
     
     
     def cmd_logs(args):
    ```

    ```diff
    @@ -5909,13 +5913,13 @@ def cmd_acp(args):
         # =========================================================================
         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
    ```
  - `scripts/install.sh`

    ```diff
    @@ -945,6 +945,7 @@ setup_path() {
         # which is always bash when piped from curl).
         if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then
             SHELL_CONFIGS=()
    +        IS_FISH=false
             LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
             case "$LOGIN_SHELL" in
                 zsh)
    ```

    ```diff
    @@ -960,6 +961,13 @@ setup_path() {
                     [ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
                     [ -f "$HOME/.bash_profile" ] && SHELL_CONFIGS+=("$HOME/.bash_profile")
                     ;;
    +            fish)
    +                # fish uses ~/.config/fish/config.fish and fish_add_path — not export PATH=
    +                IS_FISH=true
    +                FISH_CONFIG="$HOME/.config/fish/config.fish"
    +                mkdir -p "$(dirname "$FISH_CONFIG")"
    +                touch "$FISH_CONFIG"
    +                ;;
                 *)
                     [ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
                     [ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc")
    ```

    ```diff
    @@ -967,7 +975,7 @@ setup_path() {
             esac
             # Also ensure ~/.profile has it (sourced by login shells on
             # Ubuntu/Debian/WSL even when ~/.bashrc is skipped)
    -        [ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile")
    +        [ "$IS_FISH" = "false" ] && [ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile")
     
             PATH_LINE='export PATH="$HOME/.local/bin:$PATH"'
     
    ```

    ```diff
    @@ -980,7 +988,17 @@ setup_path() {
                 fi
             done
     
    -        if [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
    +        # fish uses fish_add_path instead of export PATH=...
    +        if [ "$IS_FISH" = "true" ]; then
    +            if ! grep -q 'fish_add_path.*\.local/bin' "$FISH_CONFIG" 2>/dev/null; then
    +                echo "" >> "$FISH_CONFIG"
    +                echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$FISH_CONFIG"
    +                echo 'fish_add_path "$HOME/.local/bin"' >> "$FISH_CONFIG"
    +                log_success "Added ~/.local/bin to PATH in $FISH_CONFIG"
    +            fi
    +        fi
    +
    +        if [ "$IS_FISH" = "false" ] && [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
                 log_warn "Could not detect shell config file to add ~/.local/bin to PATH"
                 log_info "Add manually: $PATH_LINE"
             fi
    ```

    ```diff
    @@ -1315,6 +1333,8 @@ print_success() {
                 echo "   source ~/.zshrc"
             elif [ "$LOGIN_SHELL" = "bash" ]; then
                 echo "   source ~/.bashrc"
    +        elif [ "$LOGIN_SHELL" = "fish" ]; then
    +            echo "   source ~/.config/fish/config.fish"
             else
                 echo "   source ~/.bashrc   # or ~/.zshrc"
             fi
    ```
  - `tests/hermes_cli/test_completion.py`

    ```diff
    @@ -0,0 +1,271 @@
    +"""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")
    +    p.add_argument("-p", "--profile", help="Profile name")
    +    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")
    +
    +    prof = sub.add_parser("profile", help="Manage profiles")
    +    prof_sub = prof.add_subparsers(dest="profile_command")
    +    prof_sub.add_parser("list", help="List profiles")
    +    prof_sub.add_parser("use", help="Switch to a profile")
    +    prof_sub.add_parser("create", help="Create a new profile")
    +    prof_sub.add_parser("delete", help="Delete a profile")
    +    prof_sub.add_parser("show", help="Show profile details")
    +    prof_sub.add_parser("alias", help="Set profile alias")
    +    prof_sub.add_parser("rename", help="Rename a profile")
    +    prof_sub.add_parser("export", help="Export a profile")
    +
    +    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", "profile", "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}"
    +
    +
    +# ---------------------------------------------------------------------------
    +# 6. Profile completion (regression prevention)
    +# ---------------------------------------------------------------------------
    +
    +class TestProfileCompletion:
    +    """Ensure profile name completion is present in all shell outputs."""
    +
    +    def test_bash_has_profiles_helper(self):
    +        out = generate_bash(_make_parser())
    +        assert "_hermes_profiles()" in out
    +        assert 'profiles_dir="$HOME/.hermes/profiles"' in out
    +
    +    def test_bash_completes_profiles_after_p_flag(self):
    +        out = generate_bash(_make_parser())
    +        assert '"-p"' in out or "== \"-p\"" in out
    +        assert '"--profile"' in out or '== "--profile"' in out
    +        assert "_hermes_profiles" in out
    +
    +    def test_bash_profile_subcommand_has_action_completion(self):
    +        out = generate_bash(_make_parser())
    +        assert "use|delete|show|alias|rename|export)" in out
    +
    +    def test_bash_profile_actions_complete_profile_names(self):
    +        """After 'hermes profile use', complete with profile names."""
    +        out = generate_bash(_make_parser())
    +        # The profile case should have _hermes_profiles for name-taking actions
    +        lines = out.split("\n")
    +        in_profile_case = False
    +        has_profiles_in_action = False
    +        for line in lines:
    +            if "profile)" in line:
    +                in_profile_case = True
    +            if in_profile_case and "_hermes_profiles" in line:
    +                has_profiles_in_action = True
    +                break
    +        assert has_profiles_in_action, "profile actions should complete with _hermes_profiles"
    +
    +    def test_zsh_has_profiles_helper(self):
    +        out = generate_zsh(_make_parser())
    +        assert "_hermes_profiles()" in out
    +        assert "$HOME/.hermes/profiles" in out
    +
    +    def test_zsh_has_profile_flag_completion(self):
    +        out = generate_zsh(_make_parser())
    +        assert "--profile" in out
    +        assert "_hermes_profiles" in out
    +
    +    def test_zsh_profile_actions_complete_names(self):
    +        out = generate_zsh(_make_parser())
    +        assert "use|delete|show|alias|rename|export)" in out
    +
    +    def test_fish_has_profiles_helper(self):
    +        out = generate_fish(_make_parser())
    +        assert "__hermes_profiles" in out
    +        assert "$HOME/.hermes/profiles" in out
    +
    +    def test_fish_has_profile_flag_completion(self):
    +        out = generate_fish(_make_parser())
    +        assert "-s p -l profile" in out
    +        assert "(__hermes_profiles)" in out
    +
    +    def test_fish_profile_actions_complete_names(self):
    +        out = generate_fish(_make_parser())
    +        # Should have profile name completion for actions like use, delete, etc.
    +        assert "__hermes_profiles" in out
    +        count = out.count("(__hermes_profiles)")
    +        # At least the -p flag + the profile action completions
    +        assert count >= 2, f"Expected >=2 profile completion entries, got {count}"
    ```
- **#9476: feat(cli): add dynamic shell completion for bash, zsh, and fish**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#9785`

## Fix status

- Merged to main in PR #9785 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-14`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `hermes_cli`
