/ hermes_cli / completion.py
completion.py
1 """Shell completion script generation for hermes CLI. 2 3 Walks the live argparse parser tree to generate accurate, always-up-to-date 4 completion scripts — no hardcoded subcommand lists, no extra dependencies. 5 6 Supports bash, zsh, and fish. 7 """ 8 9 from __future__ import annotations 10 11 import argparse 12 from typing import Any 13 14 15 def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]: 16 """Recursively extract subcommands and flags from a parser. 17 18 Uses _SubParsersAction._choices_actions to get canonical names (no aliases) 19 along with their help text. 20 """ 21 flags: list[str] = [] 22 subcommands: dict[str, Any] = {} 23 24 for action in parser._actions: 25 if isinstance(action, argparse._SubParsersAction): 26 # _choices_actions has one entry per canonical name; aliases are 27 # omitted, which keeps completion lists clean. 28 seen: set[str] = set() 29 for pseudo in action._choices_actions: 30 name = pseudo.dest 31 if name in seen: 32 continue 33 seen.add(name) 34 subparser = action.choices.get(name) 35 if subparser is None: 36 continue 37 info = _walk(subparser) 38 info["help"] = _clean(pseudo.help or "") 39 subcommands[name] = info 40 elif action.option_strings: 41 flags.extend(o for o in action.option_strings if o.startswith("-")) 42 43 return {"flags": flags, "subcommands": subcommands} 44 45 46 def _clean(text: str, maxlen: int = 60) -> str: 47 """Strip shell-unsafe characters and truncate.""" 48 return text.replace("'", "").replace('"', "").replace("\\", "")[:maxlen] 49 50 51 # --------------------------------------------------------------------------- 52 # Bash 53 # --------------------------------------------------------------------------- 54 55 def generate_bash(parser: argparse.ArgumentParser) -> str: 56 tree = _walk(parser) 57 top_cmds = " ".join(sorted(tree["subcommands"])) 58 59 cases: list[str] = [] 60 for cmd in sorted(tree["subcommands"]): 61 info = tree["subcommands"][cmd] 62 if cmd == "profile" and info["subcommands"]: 63 # Profile subcommand: complete actions, then profile names for 64 # actions that accept a profile argument. 65 subcmds = " ".join(sorted(info["subcommands"])) 66 profile_actions = "use delete show alias rename export" 67 cases.append( 68 f" profile)\n" 69 f" case \"$prev\" in\n" 70 f" profile)\n" 71 f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n" 72 f" return\n" 73 f" ;;\n" 74 f" {profile_actions.replace(' ', '|')})\n" 75 f" COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n" 76 f" return\n" 77 f" ;;\n" 78 f" esac\n" 79 f" ;;" 80 ) 81 elif info["subcommands"]: 82 subcmds = " ".join(sorted(info["subcommands"])) 83 cases.append( 84 f" {cmd})\n" 85 f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n" 86 f" return\n" 87 f" ;;" 88 ) 89 elif info["flags"]: 90 flags = " ".join(info["flags"]) 91 cases.append( 92 f" {cmd})\n" 93 f" COMPREPLY=($(compgen -W \"{flags}\" -- \"$cur\"))\n" 94 f" return\n" 95 f" ;;" 96 ) 97 98 cases_str = "\n".join(cases) 99 100 return f"""# Hermes Agent bash completion 101 # Add to ~/.bashrc: 102 # eval "$(hermes completion bash)" 103 104 _hermes_profiles() {{ 105 local profiles_dir="$HOME/.hermes/profiles" 106 local profiles="default" 107 if [ -d "$profiles_dir" ]; then 108 profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)" 109 fi 110 echo "$profiles" 111 }} 112 113 _hermes_completion() {{ 114 local cur prev 115 COMPREPLY=() 116 cur="${{COMP_WORDS[COMP_CWORD]}}" 117 prev="${{COMP_WORDS[COMP_CWORD-1]}}" 118 119 # Complete profile names after -p / --profile 120 if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then 121 COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur")) 122 return 123 fi 124 125 if [[ $COMP_CWORD -ge 2 ]]; then 126 case "${{COMP_WORDS[1]}}" in 127 {cases_str} 128 esac 129 fi 130 131 if [[ $COMP_CWORD -eq 1 ]]; then 132 COMPREPLY=($(compgen -W "{top_cmds}" -- "$cur")) 133 fi 134 }} 135 136 complete -F _hermes_completion hermes 137 """ 138 139 140 # --------------------------------------------------------------------------- 141 # Zsh 142 # --------------------------------------------------------------------------- 143 144 def generate_zsh(parser: argparse.ArgumentParser) -> str: 145 tree = _walk(parser) 146 147 top_cmds_lines: list[str] = [] 148 for cmd in sorted(tree["subcommands"]): 149 help_text = _clean(tree["subcommands"][cmd].get("help", "")) 150 top_cmds_lines.append(f" '{cmd}:{help_text}'") 151 top_cmds_str = "\n".join(top_cmds_lines) 152 153 sub_cases: list[str] = [] 154 for cmd in sorted(tree["subcommands"]): 155 info = tree["subcommands"][cmd] 156 if not info["subcommands"]: 157 continue 158 if cmd == "profile": 159 # Profile subcommand: complete actions, then profile names for 160 # actions that accept a profile argument. 161 sub_lines: list[str] = [] 162 for sc in sorted(info["subcommands"]): 163 sh = _clean(info["subcommands"][sc].get("help", "")) 164 sub_lines.append(f" '{sc}:{sh}'") 165 sub_str = "\n".join(sub_lines) 166 sub_cases.append( 167 f" profile)\n" 168 f" case ${{line[2]}} in\n" 169 f" use|delete|show|alias|rename|export)\n" 170 f" _hermes_profiles\n" 171 f" ;;\n" 172 f" *)\n" 173 f" local -a profile_cmds\n" 174 f" profile_cmds=(\n" 175 f"{sub_str}\n" 176 f" )\n" 177 f" _describe 'profile command' profile_cmds\n" 178 f" ;;\n" 179 f" esac\n" 180 f" ;;" 181 ) 182 else: 183 sub_lines = [] 184 for sc in sorted(info["subcommands"]): 185 sh = _clean(info["subcommands"][sc].get("help", "")) 186 sub_lines.append(f" '{sc}:{sh}'") 187 sub_str = "\n".join(sub_lines) 188 safe = cmd.replace("-", "_") 189 sub_cases.append( 190 f" {cmd})\n" 191 f" local -a {safe}_cmds\n" 192 f" {safe}_cmds=(\n" 193 f"{sub_str}\n" 194 f" )\n" 195 f" _describe '{cmd} command' {safe}_cmds\n" 196 f" ;;" 197 ) 198 sub_cases_str = "\n".join(sub_cases) 199 200 return f"""#compdef hermes 201 # Hermes Agent zsh completion 202 # Add to ~/.zshrc: 203 # eval "$(hermes completion zsh)" 204 205 _hermes_profiles() {{ 206 local -a profiles 207 profiles=(default) 208 if [[ -d "$HOME/.hermes/profiles" ]]; then 209 profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}") 210 fi 211 _describe 'profile' profiles 212 }} 213 214 _hermes() {{ 215 local context state line 216 typeset -A opt_args 217 218 _arguments -C \\ 219 '(-h --help){{-h,--help}}[Show help and exit]' \\ 220 '(-V --version){{-V,--version}}[Show version and exit]' \\ 221 '(-p --profile){{-p,--profile}}[Profile name]:profile:_hermes_profiles' \\ 222 '1:command:->commands' \\ 223 '*::arg:->args' 224 225 case $state in 226 commands) 227 local -a subcmds 228 subcmds=( 229 {top_cmds_str} 230 ) 231 _describe 'hermes command' subcmds 232 ;; 233 args) 234 case ${{line[1]}} in 235 {sub_cases_str} 236 esac 237 ;; 238 esac 239 }} 240 241 _hermes "$@" 242 """ 243 244 245 # --------------------------------------------------------------------------- 246 # Fish 247 # --------------------------------------------------------------------------- 248 249 def generate_fish(parser: argparse.ArgumentParser) -> str: 250 tree = _walk(parser) 251 top_cmds = sorted(tree["subcommands"]) 252 top_cmds_str = " ".join(top_cmds) 253 254 lines: list[str] = [ 255 "# Hermes Agent fish completion", 256 "# Add to your config:", 257 "# hermes completion fish | source", 258 "", 259 "# Helper: list available profiles", 260 "function __hermes_profiles", 261 " echo default", 262 " if test -d $HOME/.hermes/profiles", 263 " ls $HOME/.hermes/profiles 2>/dev/null", 264 " end", 265 "end", 266 "", 267 "# Disable file completion by default", 268 "complete -c hermes -f", 269 "", 270 "# Complete profile names after -p / --profile", 271 "complete -c hermes -f -s p -l profile" 272 " -d 'Profile name' -xa '(__hermes_profiles)'", 273 "", 274 "# Top-level subcommands", 275 ] 276 277 for cmd in top_cmds: 278 info = tree["subcommands"][cmd] 279 help_text = _clean(info.get("help", "")) 280 lines.append( 281 f"complete -c hermes -f " 282 f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' " 283 f"-a {cmd} -d '{help_text}'" 284 ) 285 286 lines.append("") 287 lines.append("# Subcommand completions") 288 289 profile_name_actions = {"use", "delete", "show", "alias", "rename", "export"} 290 291 for cmd in top_cmds: 292 info = tree["subcommands"][cmd] 293 if not info["subcommands"]: 294 continue 295 lines.append(f"# {cmd}") 296 for sc in sorted(info["subcommands"]): 297 sinfo = info["subcommands"][sc] 298 sh = _clean(sinfo.get("help", "")) 299 lines.append( 300 f"complete -c hermes -f " 301 f"-n '__fish_seen_subcommand_from {cmd}' " 302 f"-a {sc} -d '{sh}'" 303 ) 304 # For profile subcommand, complete profile names for relevant actions 305 if cmd == "profile": 306 for action in sorted(profile_name_actions): 307 lines.append( 308 f"complete -c hermes -f " 309 f"-n '__fish_seen_subcommand_from {action}; " 310 f"and __fish_seen_subcommand_from profile' " 311 f"-a '(__hermes_profiles)' -d 'Profile name'" 312 ) 313 314 lines.append("") 315 return "\n".join(lines)