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