diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index acd7c57bf..18de08cc9 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -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) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 955ac4028..7347dc4a3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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): diff --git a/tests/hermes_cli/test_completion.py b/tests/hermes_cli/test_completion.py index 78a7d01c7..20bde059f 100644 --- a/tests/hermes_cli/test_completion.py +++ b/tests/hermes_cli/test_completion.py @@ -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}"