diff --git a/docs/tutorial/commands/help.md b/docs/tutorial/commands/help.md
index d5ec229d68..3a0e7170ad 100644
--- a/docs/tutorial/commands/help.md
+++ b/docs/tutorial/commands/help.md
@@ -176,6 +176,30 @@ $ python main.py delete --help
+## Suggest Commands
+
+As of version 0.20.0, Typer added support for suggesting mistyped command names. This feature is **enabled by default**, but you can disable it with the parameter `suggest_commands=False`:
+
+{* docs_src/commands/index/tutorial005.py hl[3] *}
+
+If a user mistypes a command, they'll see a helpful suggestion:
+
+
+
+```console
+$ python main.py crate
+
+Usage: main.py [OPTIONS] COMMAND [ARGS]...
+Try 'main.py --help' for help.
+╭─ Error ───────────────────────────────────────────────────────────╮
+│ No such command 'crate'. Did you mean 'create'? │
+╰───────────────────────────────────────────────────────────────────╯
+```
+
+
+
+If there are multiple close matches, Typer will suggest them all. This feature uses Python's built-in `difflib.get_close_matches()` to find similar command names, making your CLI more user-friendly by helping users recover from typos.
+
## Rich Markdown and Markup
If you have **Rich** installed as described in [Printing and Colors](../printing.md){.internal-link target=_blank}, you can configure your app to enable markup text with the parameter `rich_markup_mode`.
diff --git a/docs_src/commands/index/tutorial005.py b/docs_src/commands/index/tutorial005.py
new file mode 100644
index 0000000000..ef9e5ded7b
--- /dev/null
+++ b/docs_src/commands/index/tutorial005.py
@@ -0,0 +1,17 @@
+import typer
+
+app = typer.Typer(suggest_commands=True)
+
+
+@app.command()
+def create():
+ typer.echo("Creating...")
+
+
+@app.command()
+def delete():
+ typer.echo("Deleting...")
+
+
+if __name__ == "__main__":
+ app()
diff --git a/tests/test_suggest_commands.py b/tests/test_suggest_commands.py
new file mode 100644
index 0000000000..71f2398bb9
--- /dev/null
+++ b/tests/test_suggest_commands.py
@@ -0,0 +1,98 @@
+import typer
+from typer.testing import CliRunner
+
+runner = CliRunner()
+
+
+def test_typo_suggestion_enabled():
+ """Test that typo suggestions work when enabled"""
+ app = typer.Typer()
+
+ @app.command()
+ def create(): # pragma: no cover
+ typer.echo("Creating...")
+
+ @app.command()
+ def delete(): # pragma: no cover
+ typer.echo("Deleting...")
+
+ result = runner.invoke(app, ["crate"])
+ assert result.exit_code != 0
+ assert "No such command" in result.output
+ assert "Did you mean 'create'?" in result.output
+
+
+def test_typo_suggestion_multiple_matches():
+ """Test that multiple suggestions are shown when there are multiple close matches"""
+ app = typer.Typer()
+
+ @app.command()
+ def create(): # pragma: no cover
+ typer.echo("Creating...")
+
+ @app.command()
+ def createnew(): # pragma: no cover
+ typer.echo("Creating new...")
+
+ result = runner.invoke(app, ["crate"])
+ assert result.exit_code != 0
+ assert "No such command" in result.output
+ assert "Did you mean" in result.output
+ assert "create" in result.output and "createnew" in result.output
+
+
+def test_typo_suggestion_no_matches():
+ """Test that no suggestions are shown when there are no close matches"""
+ app = typer.Typer()
+
+ @app.command()
+ def create(): # pragma: no cover
+ typer.echo("Creating...")
+
+ @app.command()
+ def delete(): # pragma: no cover
+ typer.echo("Deleting...")
+
+ result = runner.invoke(app, ["xyz"])
+ assert result.exit_code != 0
+ assert "No such command" in result.output
+ assert "Did you mean" not in result.output
+
+
+def test_typo_suggestion_exact_match_works():
+ """Test that exact matches still work normally"""
+ app = typer.Typer()
+
+ @app.command()
+ def create():
+ typer.echo("Creating...")
+
+ @app.command()
+ def delete():
+ typer.echo("Deleting...")
+
+ result = runner.invoke(app, ["create"])
+ assert result.exit_code == 0
+ assert "Creating..." in result.output
+
+ result = runner.invoke(app, ["delete"])
+ assert result.exit_code == 0
+ assert "Deleting..." in result.output
+
+
+def test_typo_suggestion_disabled():
+ """Test that typo suggestions can be explicitly disabled"""
+ app = typer.Typer(suggest_commands=False)
+
+ @app.command()
+ def create(): # pragma: no cover
+ typer.echo("Creating...")
+
+ @app.command()
+ def delete(): # pragma: no cover
+ typer.echo("Deleting...")
+
+ result = runner.invoke(app, ["crate"])
+ assert result.exit_code != 0
+ assert "No such command" in result.output
+ assert "Did you mean" not in result.output
diff --git a/tests/test_tutorial/test_commands/test_index/test_tutorial005.py b/tests/test_tutorial/test_commands/test_index/test_tutorial005.py
new file mode 100644
index 0000000000..4f97c74ad9
--- /dev/null
+++ b/tests/test_tutorial/test_commands/test_index/test_tutorial005.py
@@ -0,0 +1,24 @@
+from typer.testing import CliRunner
+
+from docs_src.commands.index import tutorial005 as mod
+
+app = mod.app
+runner = CliRunner()
+
+
+def test_creates_successfully():
+ """Verify the example runs without errors"""
+ result = runner.invoke(app, ["create"])
+ assert result.exit_code == 0
+ assert "Creating..." in result.output
+
+ result = runner.invoke(app, ["delete"])
+ assert result.exit_code == 0
+ assert "Deleting..." in result.output
+
+
+def test_shows_suggestion():
+ """Verify command suggestions appear for typos"""
+ result = runner.invoke(app, ["crate"])
+ assert result.exit_code != 0
+ assert "Did you mean 'create'?" in result.output
diff --git a/typer/core.py b/typer/core.py
index 048f28c137..e9631e56cf 100644
--- a/typer/core.py
+++ b/typer/core.py
@@ -3,6 +3,7 @@
import inspect
import os
import sys
+from difflib import get_close_matches
from enum import Enum
from gettext import gettext as _
from typing import (
@@ -22,7 +23,6 @@
import click
import click.core
import click.formatting
-import click.parser
import click.shell_completion
import click.types
import click.utils
@@ -750,11 +750,13 @@ def __init__(
# Rich settings
rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE,
rich_help_panel: Union[str, None] = None,
+ suggest_commands: bool = True,
**attrs: Any,
) -> None:
super().__init__(name=name, commands=commands, **attrs)
self.rich_markup_mode: MarkupMode = rich_markup_mode
self.rich_help_panel = rich_help_panel
+ self.suggest_commands = suggest_commands
def format_options(
self, ctx: click.Context, formatter: click.HelpFormatter
@@ -772,6 +774,23 @@ def _main_shell_completion(
self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var
)
+ def resolve_command(
+ self, ctx: click.Context, args: List[str]
+ ) -> Tuple[Optional[str], Optional[click.Command], List[str]]:
+ try:
+ return super().resolve_command(ctx, args)
+ except click.UsageError as e:
+ if self.suggest_commands:
+ available_commands = list(self.commands.keys())
+ if available_commands and args:
+ typo = args[0]
+ matches = get_close_matches(typo, available_commands)
+ if matches:
+ suggestions = ", ".join(f"{m!r}" for m in matches)
+ message = e.message.rstrip(".")
+ e.message = f"{message}. Did you mean {suggestions}?"
+ raise
+
def main(
self,
args: Optional[Sequence[str]] = None,
diff --git a/typer/main.py b/typer/main.py
index 2fdc09e1ac..71a25e6c4b 100644
--- a/typer/main.py
+++ b/typer/main.py
@@ -135,6 +135,7 @@ def __init__(
# Rich settings
rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE),
rich_help_panel: Union[str, None] = Default(None),
+ suggest_commands: bool = True,
pretty_exceptions_enable: bool = True,
pretty_exceptions_show_locals: bool = True,
pretty_exceptions_short: bool = True,
@@ -142,6 +143,7 @@ def __init__(
self._add_completion = add_completion
self.rich_markup_mode: MarkupMode = rich_markup_mode
self.rich_help_panel = rich_help_panel
+ self.suggest_commands = suggest_commands
self.pretty_exceptions_enable = pretty_exceptions_enable
self.pretty_exceptions_show_locals = pretty_exceptions_show_locals
self.pretty_exceptions_short = pretty_exceptions_short
@@ -330,6 +332,7 @@ def get_group(typer_instance: Typer) -> TyperGroup:
TyperInfo(typer_instance),
pretty_exceptions_short=typer_instance.pretty_exceptions_short,
rich_markup_mode=typer_instance.rich_markup_mode,
+ suggest_commands=typer_instance.suggest_commands,
)
return group
@@ -456,6 +459,7 @@ def get_group_from_info(
group_info: TyperInfo,
*,
pretty_exceptions_short: bool,
+ suggest_commands: bool,
rich_markup_mode: MarkupMode,
) -> TyperGroup:
assert group_info.typer_instance, (
@@ -475,6 +479,7 @@ def get_group_from_info(
sub_group_info,
pretty_exceptions_short=pretty_exceptions_short,
rich_markup_mode=rich_markup_mode,
+ suggest_commands=suggest_commands,
)
if sub_group.name:
commands[sub_group.name] = sub_group
@@ -523,6 +528,7 @@ def get_group_from_info(
rich_markup_mode=rich_markup_mode,
# Rich settings
rich_help_panel=solved_info.rich_help_panel,
+ suggest_commands=suggest_commands,
)
return group