feat(cli): add dynamic shell completion for bash, zsh, and fish

Replaces the hardcoded completion stubs in profiles.py with a dynamic
generator that walks the live argparse parser tree at runtime.

- New hermes_cli/completion.py: _walk() recursively extracts all
  subcommands and flags; generate_bash/zsh/fish() produce complete
  scripts with nested subcommand support
- cmd_completion now accepts the parser via closure so completions
  always reflect the actual registered commands (including plugin-
  registered ones like honcho)
- completion subcommand now accepts bash | zsh | fish (fish requested
  in issue comments)
- Fix _SUBCOMMANDS set: add honcho, claw, plugins, acp, webhook,
  memory, dump, debug, backup, import, completion, logs so that
  multi-word session names after -c/-r are not broken by these commands
- Add tests/hermes_cli/test_completion.py: 17 tests covering parser
  extraction, alias deduplication, bash/zsh/fish output content,
  bash syntax validation, fish syntax validation, and subcommand
  drift prevention

Tested on Linux (Arch). bash and fish completion verified live.
zsh script passes syntax check (zsh not installed on test machine).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
leozeli 2026-04-14 11:33:46 +08:00 committed by Teknium
parent b21b3bfd68
commit a686dbdd26
3 changed files with 432 additions and 8 deletions

223
hermes_cli/completion.py Normal file
View file

@ -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)

View file

@ -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

View file

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