1515
1616import dataclasses
1717import datetime
18+ import difflib
1819import importlib .metadata
1920import json
2021import os
@@ -107,8 +108,40 @@ class HFCliTyperGroup(TyperGroup):
107108 - supports aliases via pipe-separated names (e.g. ``name="list | ls"``).
108109 - rewrites ``--json`` to ``--format json`` for commands that accept ``--format``.
109110 - rewrites ``spaces/user/repo`` to ``user/repo --type space`` for commands that accept ``--type``.
111+ - enriches "No such option" / "No such command" errors with available options or commands.
110112 """
111113
114+ def invoke (self , ctx : click .Context ) -> None :
115+ """Enrich unknown-option errors with available options or subcommands.
116+
117+ Catches `NoSuchOption` raised during subcommand `make_context()`
118+ (option parsing). For leaf commands (e.g. `hf repos create --test`)
119+ we list the command's options; for groups (e.g. `hf cache --test`)
120+ we list subcommands since groups have no user-facing options.
121+ """
122+ try :
123+ return super ().invoke (ctx )
124+ except click .NoSuchOption as e :
125+ if e .ctx is not None and e .ctx .command is not None :
126+ cmd = e .ctx .command
127+ if isinstance (cmd , click .Group ):
128+ # Group has no user-facing options -> show subcommands instead
129+ items = [
130+ (name , sub .get_short_help_str (limit = 80 ))
131+ for name in cmd .list_commands (e .ctx )
132+ if (sub := cmd .get_command (e .ctx , name )) is not None and not sub .hidden
133+ ]
134+ _enrich_usage_error (e , "commands" , items )
135+ else :
136+ # Leaf command -> show its options using Click's rich formatting
137+ items = [
138+ record
139+ for p in cmd .get_params (e .ctx )
140+ if isinstance (p , click .Option ) and not p .hidden and (record := p .get_help_record (e .ctx ))
141+ ]
142+ _enrich_usage_error (e , "options" , items )
143+ raise
144+
112145 def resolve_command (self , ctx : click .Context , args : list [str ]) -> tuple :
113146 cmd_name = args [0 ] if args and not args [0 ].startswith ("-" ) else None
114147 cmd = self .get_command (ctx , cmd_name ) if cmd_name else None
@@ -118,7 +151,29 @@ def resolve_command(self, ctx: click.Context, args: list[str]) -> tuple:
118151 self ._rewrite_quiet_shorthand (cmd , args )
119152 self ._rewrite_repo_type_prefix (cmd , args )
120153
121- return super ().resolve_command (ctx , args )
154+ try :
155+ return super ().resolve_command (ctx , args )
156+ except click .UsageError as e :
157+ # Unknown subcommand -> add fuzzy suggestions and list available commands.
158+ if cmd is None and cmd_name is not None :
159+ # Expand aliases ("list | ls" → ["list", "ls"]) for accurate fuzzy matching.
160+ visible_names = [
161+ alias
162+ for key , registered in self .commands .items ()
163+ if not registered .hidden
164+ for alias in _ALIAS_SPLIT .split (key )
165+ ]
166+ matches = difflib .get_close_matches (cmd_name , visible_names )
167+ if matches :
168+ suggestions = ", " .join (f"'{ m } '" for m in matches )
169+ e .message = f"{ e .message .rstrip ('.' )} . Did you mean { suggestions } ?"
170+ items = [
171+ (name , sub .get_short_help_str (limit = 80 ))
172+ for name in self .list_commands (ctx )
173+ if (sub := self .get_command (ctx , name )) is not None and not sub .hidden
174+ ]
175+ _enrich_usage_error (e , "commands" , items )
176+ raise
122177
123178 @staticmethod
124179 def _rewrite_json_shorthand (cmd : click .Command , args : list [str ]) -> None :
@@ -327,6 +382,21 @@ def list_commands(self, ctx: click.Context) -> list[str]: # type: ignore[name-d
327382 return sorted (primary_names )
328383
329384
385+ def _enrich_usage_error (error : click .UsageError , label : str , items : list [tuple [str , str ]]) -> None :
386+ """Append a list of available options or commands to a usage error message."""
387+ if not items or error .ctx is None or f"Available { label } for" in error .message :
388+ return
389+ cmd_path = error .ctx .command_path
390+ lines = [f"\n \n Available { label } for '{ cmd_path } ':" ]
391+ for name , help_text in items :
392+ lines .append (f" { name :30s} { help_text } " )
393+ lines .append (f"\n Run '{ cmd_path } --help' for full details." )
394+ if isinstance (error , click .NoSuchOption ) and error .possibilities :
395+ lines .append (f"\n Did you mean: { ', ' .join (sorted (error .possibilities ))} ?" )
396+ error .possibilities = []
397+ error .message += "\n " .join (lines )
398+
399+
330400def fallback_typer_group_factory (
331401 fallback_handler : FallbackHandlerT ,
332402 extra_commands_provider : Callable [[], list [tuple [str , str ]]] | None = None ,
@@ -428,6 +498,10 @@ def typer_factory(help: str, epilog: str | None = None, cls: type[TyperGroup] |
428498 rich_markup_mode = None ,
429499 rich_help_panel = None ,
430500 pretty_exceptions_enable = False ,
501+ # Disable TyperGroup's suggest_commands, it matches against raw aliased
502+ # keys ("list | ls") leaking pipe syntax into user-facing messages.
503+ # HFCliTyperGroup.resolve_command() handles suggestions with expanded names.
504+ suggest_commands = False ,
431505 # Increase max content width for better readability
432506 context_settings = {
433507 "max_content_width" : 120 ,
0 commit comments