fix: preserve profile name completion in dynamic shell completion

The dynamic parser walker from the contributor's commit lost the profile
name tab-completion that existed in the old static generators. This adds
it back for all three shells:

- Bash: _hermes_profiles() helper, -p/--profile completion, profile
  action→name completion (use/delete/show/alias/rename/export)
- Zsh: _hermes_profiles() function, -p/--profile argument spec, profile
  action case with name completion
- Fish: __hermes_profiles function, -s p -l profile flag, profile action
  completions

Also removes the dead fallback path in cmd_completion() that imported
the old static generators from profiles.py (parser is always available
via the lambda wiring) and adds 11 regression-prevention tests for
profile completion.
This commit is contained in:
Teknium 2026-04-14 10:30:43 -07:00 committed by Teknium
parent c95b1c5096
commit b867171291
3 changed files with 196 additions and 30 deletions

View file

@ -59,7 +59,26 @@ def generate_bash(parser: argparse.ArgumentParser) -> str:
cases: list[str] = []
for cmd in sorted(tree["subcommands"]):
info = tree["subcommands"][cmd]
if info["subcommands"]:
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"
@ -82,12 +101,27 @@ def generate_bash(parser: argparse.ArgumentParser) -> str:
# 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}
@ -121,21 +155,46 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str:
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" ;;"
)
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
@ -143,6 +202,15 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str:
# 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
@ -150,6 +218,7 @@ _hermes() {{
_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'
@ -187,9 +256,21 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
"# 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",
]
@ -205,6 +286,8 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
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"]:
@ -218,6 +301,15 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
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)

View file

@ -4428,20 +4428,12 @@ def cmd_completion(args, parser=None):
"""Print shell completion script."""
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
shell = getattr(args, "shell", "bash")
if parser is not None:
if shell == "zsh":
print(generate_zsh(parser))
elif shell == "fish":
print(generate_fish(parser))
else:
print(generate_bash(parser))
if shell == "zsh":
print(generate_zsh(parser))
elif shell == "fish":
print(generate_fish(parser))
else:
# 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())
print(generate_bash(parser))
def cmd_logs(args):

View file

@ -20,6 +20,7 @@ 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")
@ -39,6 +40,17 @@ def _make_parser() -> argparse.ArgumentParser:
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
@ -51,7 +63,7 @@ def _make_parser() -> argparse.ArgumentParser:
class TestWalk:
def test_top_level_subcommands_extracted(self):
tree = _walk(_make_parser())
assert set(tree["subcommands"].keys()) == {"chat", "gateway", "sessions", "version"}
assert set(tree["subcommands"].keys()) == {"chat", "gateway", "sessions", "profile", "version"}
def test_nested_subcommands_extracted(self):
tree = _walk(_make_parser())
@ -187,3 +199,73 @@ class TestSubcommandDrift:
}
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}"