Repository: mindstorm38/portablemc

Issue:
portablemc start forge:1.20.1-latest does not work
Log:
```
[FAILED] Version forge-1.20.1-??? not found
```

Hints from issue comments:
@MisileLab: 1.20.1 has no latest, it has only recommended so maybe when latest not found, use recommended as fallback@mindstorm38: Should be how it works, hope it's not new issue with neoforge@mindstorm38: Ok I think that I understand, you start with `forge:1.20.1-latest`? If this is the case, just use `forge:1.20.1` instead, it uses recommended and then latest as fallback, should be what you need?@MisileLab: I want latest at first and recommend as fallback
(because I want newer version)@mindstorm38: So are you suggesting inverting the default fallback order or just be less strict about -latest and -recommended when one is not available? (I prefer the latter)@MisileLab: I prefer last one(just give warning when not avaliable and fallback)@mindstorm38: Looks good to me, I'll work on that.@mindstorm38: Implemented in next release!@mindstorm38: Release 4.2.1, implemented this fallback

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index aaeaded..3020daa 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,7 +11,7 @@ jobs:
       fail-fast: true
       matrix:
         os: [Ubuntu, macOS, Windows]
-        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+        python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
         include:
           - os: Ubuntu
             image: ubuntu-22.04
@@ -38,7 +38,7 @@ jobs:
         run: |
           env\Scripts\Activate.ps1
           pip install poetry pytest-github-actions-annotate-failures
-          poetry install
+          poetry install --all-extras
           coverage run -m pytest -v test/
 
       - name: Test on UNIX
@@ -46,7 +46,7 @@ jobs:
         run: |
           source env/bin/activate
           pip install poetry pytest-github-actions-annotate-failures
-          poetry install
+          poetry install --all-extras
           coverage run -m pytest -v test/
       
       - name: Coverage & codecov
diff --git a/README.md b/README.md
index 9d5f280..e9aedf9 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,18 @@
 # Portable Minecraft Launcher
 A fast, reliable and cross-platform command-line Minecraft launcher and API for developers.
-This launcher is compatible with the official Minecraft launcher's version specification.
-It also includes fast installation of common mod loaders such as Fabric, Forge and Quilt.
+Including fast and easy installation of common mod loaders such as Fabric, Forge, NeoForge and Quilt.
+This launcher is compatible with the standard Minecraft directories. 
 
-![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square) &nbsp;![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)
+[![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)](https://pypi.org/project/portablemc/)
 
-![illustration](doc/illustration.png)
+![illustration](https://github.com/mindstorm38/portablemc/blob/main/doc/illustration.png)
 
-*This launcher is tested for Python 3.7, 3.8, 3.9, 3.10, 3.11.*
+*This launcher is tested for Python 3.8, 3.9, 3.10, 3.11, 3.12.*
 
 ## Table of contents
 - [Installation](#installation)
+  - [With pip](#with-pip)
+  - [With Arch Linux](#with-arch-linux)
 - [Commands](#commands)
   - [Start Minecraft](#start-minecraft)
     - [Authentication](#authentication)
@@ -22,33 +24,48 @@ It also includes fast installation of common mod loaders such as Fabric, Forge a
     - [Miscellaneous](#miscellaneous)
   - [Search for versions](#search-for-versions)
   - [Authentication sessions](#authentication-sessions)
+  - [Shell completion](#shell-completion)
 - [Offline support](#offline-support)
 - [Certifi support](#certifi-support)
 - [Contribute](#contribute)
   - [Setup environment](#setup-environment)
   - [Contributors](#contributors)
   - [Sponsors](#sponsors)
-- [API Documentation (v4) ⇗](doc/API.md)
+- [API Documentation (v4.2) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md)
 
 ## Installation
+
+### With pip
+
 This launcher can be installed using `pip`.  On some linux distribution you might have to 
 use `pip3` instead of `pip` in order to run it on Python 3. You can also use 
 `python -m pip` if the `pip` command is not in the path and the python executable is.
 
 ```sh
-pip install --user portablemc
+pip install --user portablemc[certifi]
 ```
 
-It's recommended to keep `--user` because this installs the launcher for your
-current user only, it is implicit if you are not an administrator and if you are, it 
-allows not to modify other users' installations.
-
 After that, you can try to show the launcher help message using `portablemc` in your 
 terminal. If it fails, you should check that the scripts directory is in your user path
 environment variable. On Windows you have to search for a directory at 
 `%appdata%/Python/Python3X/Scripts` and add it to the user's environment variable `Path`. 
 On UNIX systems it's `~/.local/bin`.
 
+You can opt-out from the `certifi` optional feature if you don't want to depend on it,
+learn more in the [Certifi support](#certifi-support) section.
+
+> [!TIP]
+> It's recommended to keep `--user` because this installs the launcher for your current 
+> user only and does not pollute other's environments, it is implicit if you are not an 
+> administrator and if you are, it allows not to modify other users' installations.
+
+### With Arch Linux
+
+For Arch Linux users, the package is available as `portablemc` in the 
+[AUR](https://aur.archlinux.org/packages/portablemc).
+
+*This is currently maintained by Maks Jopek, Thanks!*
+
 ## Commands
 Arguments are split between multiple commands. 
 For example `portablemc [global-args] <cmd> [args]`. 
@@ -219,8 +236,10 @@ these are discarded in the bin directory, for example
 These arguments can be used together to fix various issues (e.g. wrong libc being linked
 by the LWJGL-provided natives).
 
-*Note that these arguments are compatible with, and executed after the `--lwjgl` argument.
-You must however ensure that excluded lib and included binaries are compatible.*
+> [!NOTE]
+> Note that these arguments are compatible with, and executed after the `--lwjgl` 
+> argument. You must however ensure that excluded lib and included binaries are 
+> compatible.
 
 #### Miscellaneous
 With `--dry`, the start command does not start the game, but simply installs it.
@@ -257,6 +276,16 @@ you need to log into an old Mojang account (being phased out by Mojang).
 **Your password is not saved!** Only tokens are saved *(the official launcher also does 
 that)* in the file `portablemc_auth.json` in the working directory.
 
+### Shell completion
+The launcher can generate shell completions scripts for Bash and Zsh shells through the
+`portablemc show completion {bash,zsh}` command. If you need precise explanation on how
+to install the completions, read this command's help message. **This command needs to be
+re-run for every new version of the launcher**, you're not affected if you directly eval
+the result.
+
+*Note that Zsh completion scripts can be used both as an auto-load script and as
+evaluated one.*
+
 ## Offline support
 This launcher can be used without internet access under certain conditions. Launching
 versions is possible if all required resources are locally installed, it is also possible
diff --git a/doc/API.md b/doc/API.md
index d9227f5..d2e2ad3 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -3,7 +3,7 @@ This page documents the public API of the launcher. This launcher
 library provides high flexibility for launching Minecraft in many
 environments.
 
-Documented version: `4.0.0`.
+Documented version: `4.2`.
 
 ## Table of contents
 - [File structure](#file-structure)
diff --git a/portablemc/__init__.py b/portablemc/__init__.py
index 9d745c4..84cd447 100644
--- a/portablemc/__init__.py
+++ b/portablemc/__init__.py
@@ -7,7 +7,7 @@ public API, and therefore stable across minor and patch version bumps.
 """
 
 LAUNCHER_NAME = "portablemc"
-LAUNCHER_VERSION = "4.1.0"
-LAUNCHER_AUTHORS = ["Théo Rozier <contact@theorozier.fr>", "Github contributors"]
-LAUNCHER_COPYRIGHT = "PortableMC  Copyright (C) 2021-2023  Théo Rozier"
+LAUNCHER_VERSION = "4.2.1"
+LAUNCHER_AUTHORS = ["Théo Rozier <contact@theorozier.fr>", "Contributors"]
+LAUNCHER_COPYRIGHT = "PortableMC  Copyright (C) 2021-2024  Théo Rozier"
 LAUNCHER_URL = "https://github.com/mindstorm38/portablemc"
diff --git a/portablemc/__main__.py b/portablemc/__main__.py
index a2cd07c..a67e679 100644
--- a/portablemc/__main__.py
+++ b/portablemc/__main__.py
@@ -2,7 +2,7 @@
 
 # encoding: utf-8
 
-# Copyright (C) 2021-2023  Théo Rozier
+# Copyright (C) 2021-2024  Théo Rozier
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
diff --git a/portablemc/auth.py b/portablemc/auth.py
index 197b4fb..6bcaae8 100644
--- a/portablemc/auth.py
+++ b/portablemc/auth.py
@@ -166,10 +166,16 @@ class YggdrasilAuthSession(AuthSession):
                 content_type="application/json")
             return res.status, res.json()
         except HttpError as error:
-            if raise_error:
-                raise AuthError(error.res.json()["errorMessage"])
-            else:
-                return error.res.status, error.res.json()
+            try:
+                if raise_error:
+                    raise AuthError(error.res.json()["errorMessage"])
+                else:
+                    return error.res.status, error.res.json()
+            except json.JSONDecodeError:
+                if raise_error:
+                    raise AuthError("invalid json")
+                else:
+                    return error.res.status, {}
 
 
 class MicrosoftAuthSession(AuthSession):
@@ -231,6 +237,7 @@ class MicrosoftAuthSession(AuthSession):
 
     @staticmethod
     def get_authentication_url(app_id: str, redirect_uri: str, email: str, nonce: str):
+        """deprecated"""
         return "https://login.live.com/oauth20_authorize.srf?{}".format(url_parse.urlencode({
             "client_id": app_id,
             "redirect_uri": redirect_uri,
@@ -243,6 +250,7 @@ class MicrosoftAuthSession(AuthSession):
 
     @staticmethod
     def get_logout_url(app_id: str, redirect_uri: str):
+        """deprecated"""
         return "https://login.live.com/oauth20_logout.srf?{}".format(url_parse.urlencode({
             "client_id": app_id,
             "redirect_uri": redirect_uri
@@ -250,6 +258,7 @@ class MicrosoftAuthSession(AuthSession):
 
     @classmethod
     def check_token_id(cls, token_id: str, email: str, nonce: str) -> bool:
+        """deprecated"""
         id_token_payload = cls.decode_jwt_payload(token_id)
         return id_token_payload["nonce"] == nonce and id_token_payload["email"].casefold() == email.casefold()
 
diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py
index 71c7451..233481e 100644
--- a/portablemc/cli/__init__.py
+++ b/portablemc/cli/__init__.py
@@ -12,7 +12,8 @@ from pathlib import Path
 import socket
 import sys
 
-from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs
+from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs, AuthBaseNs, ShowCompletionNs
+
 from .util import format_locale_date, format_time, format_number, anonymize_email
 from .output import Output, HumanOutput, MachineOutput, OutputTable
 from .lang import get as _, lang
@@ -70,6 +71,7 @@ def main(args: Optional[List[str]] = None):
     ns: RootNs = cast(RootNs, parser.parse_args(args or sys.argv[1:]))
 
     # Setup common objects in the namespace.
+    ns.parser = parser
     ns.out = get_output(ns.out_kind)
     ns.context = Context(ns.main_dir, ns.work_dir)
     ns.version_manifest = VersionManifest(ns.context.work_dir / MANIFEST_CACHE_FILE_NAME)
@@ -124,11 +126,8 @@ def get_command_handlers() -> CommandTree:
             "about": cmd_show_about,
             "auth": cmd_show_auth,
             "lang": cmd_show_lang,
+            "completion": cmd_show_completion,
         },
-        # "addon": {
-        #     "list": cmd_addon_list,
-        #     "show": cmd_addon_show
-        # }
     }
 
 
@@ -314,7 +313,7 @@ def cmd_start(ns: StartNs):
     version.disable_chat = ns.disable_chat
     version.demo = ns.demo
     version.resolution = ns.resolution
-    version.jvm_path = None if ns.jvm is None else Path(ns.jvm)
+    version.jvm_path = ns.jvm
 
     if ns.server is not None:
         version.set_quick_play_multiplayer(ns.server, ns.server_port or 25565)
@@ -326,7 +325,7 @@ def cmd_start(ns: StartNs):
         version.fixes[Version.FIX_LWJGL] = ns.lwjgl
 
     if ns.login is not None:
-        version.auth_session = prompt_authenticate(ns, ns.login, not ns.temp_login, ns.auth_service, ns.auth_anonymize)
+        version.auth_session = prompt_authenticate(ns, ns.login, not ns.temp_login, ns.auth_anonymize)
         if version.auth_session is None:
             sys.exit(EXIT_FAILURE)
     else:
@@ -376,7 +375,6 @@ def cmd_start(ns: StartNs):
             # Included binaries
             if ns.include_bin is not None:
                 for bin_path in ns.include_bin:
-                    bin_path = Path(bin_path)
                     if not bin_path.is_file():
                         ns.out.task("FAILED", "start.additional_binary_not_found", path=bin_path)
                         ns.out.finish()
@@ -468,7 +466,7 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers
 
 
 def cmd_login(ns: LoginNs):
-    session = prompt_authenticate(ns, ns.email_or_username, True, ns.auth_service)
+    session = prompt_authenticate(ns, ns.email_or_username, True)
     if session is not None:
         ns.out.task("INFO", "login.tip.remember_start_login", email=ns.email_or_username)
         ns.out.finish()
@@ -542,13 +540,29 @@ def cmd_show_lang(ns: RootNs):
     table.print()
 
 
-def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, anonymise: bool = False) -> Optional[AuthSession]:
+def cmd_show_completion(ns: ShowCompletionNs):
+    
+    from .complete import gen_zsh_completion, gen_bash_completion
+
+    if ns.shell == "zsh":
+        content = gen_zsh_completion(ns.parser)
+    elif ns.shell == "bash":
+        content = gen_bash_completion(ns.parser)
+    else:
+        raise RuntimeError
+    
+    print(content, end="")
+
+
+def prompt_authenticate(ns: AuthBaseNs, email: str, caching: bool, anonymise: bool = False) -> Optional[AuthSession]:
     """Prompt the user to login using the given email (or legacy username) for specific 
     service (Microsoft or Yggdrasil) and return the :class:`AuthSession` if successful, 
     None otherwise. This function handles task printing and all exceptions are caught 
     internally.
     """
 
+    service = ns.auth_service
+
     session_class = {
         "microsoft": MicrosoftAuthSession,
         "yggdrasil": YggdrasilAuthSession,
@@ -556,6 +570,10 @@ def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, ano
 
     ns.auth_database.load()
 
+    if service == "yggdrasil":
+        ns.out.task("WARN", "auth.yggdrasil.deprecated")
+        ns.out.finish()
+
     task_text = f"auth.{service}"
     email_text = anonymize_email(email) if anonymise else email
 
@@ -591,8 +609,7 @@ def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, ano
             session = prompt_yggdrasil_authenticate(ns, email)
         
     except AuthError as error:
-        ns.out.task("FAILED", None)
-        ns.out.task(None, "auth.error", message=str(error))
+        ns.out.task("FAILED", "auth.error", message=str(error))
         ns.out.finish()
         return None
 
@@ -611,7 +628,7 @@ def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, ano
 
 def prompt_yggdrasil_authenticate(ns: RootNs, email_or_username: str) -> Optional[YggdrasilAuthSession]:
     ns.out.finish()
-    ns.out.task(None, "auth.yggdrasil.enter_password")
+    ns.out.task("..", "auth.yggdrasil.enter_password")
     password = ns.out.prompt(password=True)
     if password is None:
         ns.out.task("FAILED", "cancelled")
@@ -621,7 +638,7 @@ def prompt_yggdrasil_authenticate(ns: RootNs, email_or_username: str) -> Optiona
         return YggdrasilAuthSession.authenticate(ns.auth_database.get_client_id(), email_or_username, password)
 
 
-def prompt_microsoft_authenticate(ns: RootNs, email: str) -> Optional[MicrosoftAuthSession]:
+def prompt_microsoft_authenticate(ns: AuthBaseNs, email: str) -> Optional[MicrosoftAuthSession]:
 
     from .. import LAUNCHER_NAME, LAUNCHER_VERSION
     from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -629,103 +646,117 @@ def prompt_microsoft_authenticate(ns: RootNs, email: str) -> Optional[MicrosoftA
     import urllib.parse
     import webbrowser
 
-    server_port = 12782
+    nonce = uuid4().hex
     app_id = MICROSOFT_AZURE_APP_ID
-    redirect_auth = f"http://localhost:{server_port}"
-    code_redirect_uri = f"{redirect_auth}/code"
-    exit_redirect_uri = f"{redirect_auth}/exit"
+    redirect_uri = "https://www.theorozier.fr/portablemc/auth"
+
+    def gen_auth_url(state: str) -> str:
+        return "https://login.live.com/oauth20_authorize.srf?{}".format(urllib.parse.urlencode({
+            "client_id": app_id,
+            "redirect_uri": redirect_uri,
+            "response_type": "code id_token",
+            "scope": "xboxlive.signin offline_access openid email",
+            "login_hint": email,
+            "nonce": nonce,
+            "state": state,
+            "prompt": "login",
+            "response_mode": "fragment"
+        }))
+    
+    auth_query = None
+    
+    if not ns.auth_no_browser:
 
-    nonce = uuid4().hex
+        class AuthServer(HTTPServer):
 
-    auth_url = MicrosoftAuthSession.get_authentication_url(app_id, code_redirect_uri, email, nonce)
-    if not webbrowser.open(auth_url):
-        ns.out.finish()
-        ns.out.task("FAILED", "auth.microsoft.no_browser")
-        ns.out.finish()
-        return None
+            def __init__(self):
+                super().__init__(("127.0.0.1", 0), RequestHandler)
+                self.timeout = 0.5
+                self.ms_auth_query: Optional[str] = None
+
+        class RequestHandler(BaseHTTPRequestHandler):
 
-    class AuthServer(HTTPServer):
-
-        def __init__(self):
-            super().__init__(("", server_port), RequestHandler)
-            self.timeout = 0.5
-            self.ms_auth_done = False
-            self.ms_auth_id_token: Optional[str] = None
-            self.ms_auth_code: Optional[str] = None
-
-    class RequestHandler(BaseHTTPRequestHandler):
-
-        server_version = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION}"
-
-        def __init__(self, request, client_address: Tuple[str, int], auth_server: AuthServer) -> None:
-            super().__init__(request, client_address, auth_server)
-
-        def log_message(self, _format: str, *args: Any):
-            return
-
-        def send_auth_response(self, msg: str):
-            self.end_headers()
-            self.wfile.write("{}\n\n{}".format(msg, _('auth.microsoft.close_tab_and_return') if cast(AuthServer, self.server).ms_auth_done else "").encode())
-            self.wfile.flush()
-
-        def do_POST(self):
-            if self.path.startswith("/code") and self.headers.get_content_type() == "application/x-www-form-urlencoded":
-                content_length = int(self.headers["Content-Length"])
-                qs = urllib.parse.parse_qs(self.rfile.read(content_length).decode())
-                auth_server = cast(AuthServer, self.server)
-                if "code" in qs and "id_token" in qs:
-                    self.send_response(307)
-                    # We log out the user directly after authorization, this just clear the browser cache to allow
-                    # another user to authenticate with another email after. This doesn't invalid the access token.
-                    self.send_header("Location", MicrosoftAuthSession.get_logout_url(app_id, exit_redirect_uri))
-                    auth_server.ms_auth_id_token = qs["id_token"][0]
-                    auth_server.ms_auth_code = qs["code"][0]
-                    self.send_auth_response("Redirecting...")
-                elif "error" in qs:
-                    self.send_response(400)
-                    auth_server.ms_auth_done = True
-                    self.send_auth_response("Error: {} ({}).".format(qs["error_description"][0], qs["error"][0]))
+            server_version = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION}"
+
+            def __init__(self, request, client_address: Tuple[str, int], auth_server: AuthServer) -> None:
+                super().__init__(request, client_address, auth_server)
+
+            def log_message(self, _format: str, *args: Any):
+                return
+
+            def do_GET(self):
+                parsed = urllib.parse.urlparse(self.path)
+                if parsed.path in ("", "/"):
+                    cast(AuthServer, self.server).ms_auth_query = parsed.query
+                    self.send_response(200)
                 else:
                     self.send_response(404)
-                    self.send_auth_response("Missing parameters.")
-            else:
-                self.send_response(404)
-                self.send_auth_response("Unexpected page.")
-
-        def do_GET(self):
-            auth_server = cast(AuthServer, self.server)
-            if self.path.startswith("/exit"):
-                self.send_response(200)
-                auth_server.ms_auth_done = True
-                self.send_auth_response("Logged in.")
-            else:
-                self.send_response(404)
-                self.send_auth_response("Unexpected page.")
+                self.send_header("Access-Control-Allow-Origin", "*")
+                self.end_headers()
+                self.wfile.flush()
+    
+        # We start be creating the authentication server, at this point we don't start it 
+        # but we have allocated a free port.
+        with AuthServer() as server:
+
+            # First try opening the authentication page with the local webbrowser
+            if webbrowser.open(gen_auth_url(f"port:{server.server_port}")):
+                # If successfully opened the browser, we actually start the web server.
+                ns.out.task("..", "auth.microsoft.opening_browser_and_listening")
+                try:
+                    while server.ms_auth_query is None:
+                        server.handle_request()
+                except KeyboardInterrupt:
+                    pass
+            
+            if server.ms_auth_query is None:
+                ns.out.finish()
+                ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate")
+                ns.out.finish()
+                return None
 
-    ns.out.task("..", "auth.microsoft.opening_browser_and_listening")
+            auth_query = server.ms_auth_query
 
-    with AuthServer() as server:
-        try:
-            while not server.ms_auth_done:
-                server.handle_request()
-        except KeyboardInterrupt:
-            pass
+    # If here we have code or id token none, it means that no web browser has been opened.
+    # So we want to print the URL auth URL so the user can try manually.
+    if auth_query is None:
 
-    if server.ms_auth_code is None or server.ms_auth_id_token is None:
+        ns.out.task("INFO", "auth.microsoft.no_browser_fallback")
         ns.out.finish()
-        ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate")
-        ns.out.finish()
-        return None
-    else:
-        ns.out.task("..", "auth.microsoft.processing")
-        if MicrosoftAuthSession.check_token_id(server.ms_auth_id_token, email, nonce):
-            return MicrosoftAuthSession.authenticate(ns.auth_database.get_client_id(), app_id, server.ms_auth_code, code_redirect_uri)
+        ns.out.print(gen_auth_url("") + "\n")
+
+        ns.out.task("..", "auth.microsoft.no_browser_code")
+        auth_query = ns.out.prompt()
+        if auth_query is None:
+            ns.out.finish()
+            ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate")
+            ns.out.finish()
+            return None
         else:
+            auth_query = auth_query.strip()
+
+    qs = urllib.parse.parse_qs(auth_query)
+
+    if "code" in qs and "id_token" in qs:
+
+        ns.out.task("..", "auth.microsoft.processing")
+        id_token = qs["id_token"][0]
+        code = qs["code"][0]
+
+        if not MicrosoftAuthSession.check_token_id(id_token, email, nonce):
             ns.out.finish()
             ns.out.task("FAILED", "auth.microsoft.incoherent_data")
             ns.out.finish()
             return None
 
+        return MicrosoftAuthSession.authenticate(ns.auth_database.get_client_id(), app_id, code, redirect_uri)
+
+    else:
+        ns.out.finish()
+        ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate")
+        ns.out.finish()
+        return None
+
     
 class StartWatcher(SimpleWatcher):
 
diff --git a/portablemc/cli/complete.py b/portablemc/cli/complete.py
new file mode 100644
index 0000000..bc2bbda
--- /dev/null
+++ b/portablemc/cli/complete.py
@@ -0,0 +1,251 @@
+from io import StringIO
+from argparse import ArgumentParser, \
+    _CountAction, _StoreAction, _SubParsersAction, \
+    _StoreConstAction, _HelpAction, _AppendConstAction
+
+from .lang import get as _
+from .parse import type_path, type_path_dir, \
+    type_email_or_username, type_host, get_completions
+
+from typing import Dict, Tuple, cast
+
+
+def gen_zsh_completion(parser: ArgumentParser) -> str:
+    buffer = StringIO()
+    buffer.write("#compdef portablemc\n\n")
+    gen_zsh_parser_completion(parser, buffer, "_portablemc")
+    buffer.write("if [[ $zsh_eval_context[-1] == loadautofunc ]]; then\n")
+    buffer.write("  _portablemc\n")
+    buffer.write("else\n")
+    buffer.write("  compdef _portablemc portablemc\n")
+    buffer.write("fi\n")
+    return buffer.getvalue()
+
+def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str):
+
+    # Sources:
+    # - https://zsh.sourceforge.io/Doc/Release/Completion-Widgets.html
+    # - https://zsh.sourceforge.io/Doc/Release/Completion-System.html
+
+    commands: Dict[str, Tuple[str, ArgumentParser]] = {}
+    completions: Dict[str, Dict[str, str]] = {}
+
+    buffer.write(function)
+    buffer.write(" () {\n")
+    buffer.write("  local curcontext=$curcontext state line\n")
+    buffer.write("  integer ret=1\n")
+
+    buffer.write("  _arguments -s -C \\\n")
+
+    for action in parser._actions:
+        
+        zsh_description = escape_zsh(action.help or "")
+        zsh_repeat = ""
+        zsh_action = ": :"
+
+        # Depending on the action type there are some specific things we can do.
+        if isinstance(action, _CountAction):
+            zsh_repeat = "\\*"
+            zsh_action = ""
+        elif isinstance(action, _StoreAction):
+
+            action_completions = get_completions(action)
+            if action.choices is not None:
+                for choice in action.choices:
+                    if choice not in action_completions:
+                        action_completions[choice] = ""
+            
+            if action.type == type_path:
+                zsh_action = ": :_files"
+            elif action.type == type_path_dir:
+                zsh_action = ": :_files -/"
+            elif action.type == type_email_or_username:
+                zsh_action = ": :_email_addresses -c"
+            elif action.type == type_host:
+                zsh_action = ": :_hosts"
+            elif len(action_completions):
+                zsh_action = f": :->arg_{action.dest}"
+                completions[f"arg_{action.dest}"] = action_completions
+
+        elif isinstance(action, (_HelpAction, _StoreConstAction, _AppendConstAction)):
+            zsh_action = ""
+        elif isinstance(action, _SubParsersAction):
+            parsers_choices = cast(Dict[str, ArgumentParser], action.choices)
+            for sub_action in action._get_subactions():
+                commands[sub_action.dest] = (sub_action.help or "", parsers_choices[sub_action.dest])
+            continue
+
+        # If the argument is positional.
+        if not len(action.option_strings):
+            buffer.write(f"    '{zsh_action}' \\\n")
+            continue
+        
+        # If the argument is an option.
+        if len(action.option_strings) > 1:
+            zsh_names = f"{{{','.join(action.option_strings)}}}"
+        else:
+            zsh_names = action.option_strings[0]
+        buffer.write(f"    {zsh_repeat}{zsh_names}'[{zsh_description}]{zsh_action}' \\\n")
+    
+    if len(commands):
+        buffer.write("    ': :->command' \\\n")
+        buffer.write("    '*:: :->option' \\\n")
+
+    buffer.write("    && ret=0\n")
+
+    if len(commands) or len(completions):
+
+        buffer.write("  case $state in\n")
+
+        if len(commands):
+            buffer.write("  command)\n")
+            buffer.write("    local -a commands=(\n")
+            for name, (cmd_description, cmd_parser) in commands.items():
+                buffer.write(f"      '{name}:{escape_zsh(cmd_description)}'\n")
+            buffer.write("    )\n")
+            buffer.write("    _describe -t commands command commands && ret=0\n")
+            buffer.write("    ;;\n")
+
+            buffer.write("  option)\n")
+            buffer.write("    case $line[1] in\n")
+            for name, (cmd_description, cmd_parser) in commands.items():
+                buffer.write(f"    {name}) {function}_{name} ;;\n")
+            buffer.write("    esac\n")
+            buffer.write("    ;;\n")
+
+        for state, action_completions in completions.items():
+            buffer.write(f"  {state})\n")
+            buffer.write("    local -a completions=(\n")
+            for name, description in action_completions.items():
+                if len(description):
+                    buffer.write(f"      '{escape_zsh(name)}:{escape_zsh(description)}'\n")
+                else:
+                    buffer.write(f"      '{escape_zsh(name)}'\n")
+            buffer.write("    )\n")
+            buffer.write("    _describe -t values value completions && ret=0\n")
+            buffer.write("    ;;\n")
+        
+        buffer.write("  esac\n")
+
+    buffer.write("}\n\n")
+
+    for cmd_name, (cmd_description, cmd_parser) in commands.items():
+        gen_zsh_parser_completion(cmd_parser, buffer, f"{function}_{cmd_name}")
+
+def escape_zsh(s: str) -> str:
+    return s.replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:")
+
+
+def gen_bash_completion(parser: ArgumentParser) -> str:
+    buffer = StringIO()
+    buffer.write("#/usr/bin/env bash\n\n")
+    gen_bash_parser_completion(parser, buffer, "_portablemc")
+    buffer.write("\ncomplete -o filenames -o nosort -F _portablemc portablemc\n")
+    return buffer.getvalue()
+
+def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str):
+
+    # Note: We use single quote in this function because we double quote in bash.
+    # Sources:
+    # - https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion-Builtins.html
+    # - https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html
+    #
+    # Current limitations of the bash completer:
+    # - Choices for positional arguments are not support, luckily the launcher don't use 
+    #   such construct.
+    # - Short arguments cannot be completed when stacked.
+
+    buffer.write(function)
+    buffer.write(' ()\n{\n')
+
+    # We guess that there we always be two argument, the command and the argument to comp.
+    buffer.write('  local index="$(( COMP_CWORD - 1 ))"\n')
+    buffer.write('  local words=(${COMP_WORDS[@]:1})\n')
+    buffer.write('  local word="${words[$index]}"\n')
+
+    # Start by finding if there are sub parsers.
+    commands: Dict[str, ArgumentParser] = {}
+    for action in parser._actions:
+        if isinstance(action, _SubParsersAction):
+            commands.update(action.choices)
+        elif len(action.option_strings):
+            # Named argument
+            buffer.write(f'  local arg_{action.dest}="{" ".join(action.option_strings)}"\n')
+    
+    # Write a loop to find potential sub-command, and construct arguments list.
+    # We overwrite the COMP_ variables because we don't use them after loop.
+    buffer.write('  for i in ${!words[@]}; do\n')
+
+    # Start by sub-commands...
+    if len(commands):
+        buffer.write("    if (( i < index )); then\n")
+        buffer.write("      COMP_WORDS=(${words[@]:$i})\n")
+        buffer.write("      COMP_CWORD=$((index - i))\n")
+        buffer.write('      case "${words[$i]}" in\n')
+        for cmd_name, cmd_parser in commands.items():
+            buffer.write(f'      {cmd_name}) {function}_{cmd_name}; return ;;\n')
+        buffer.write("      esac\n")
+        buffer.write("    fi\n")
+    
+    # Then arguments...
+    buffer.write('    case "${words[$i]}" in\n')
+    for action in parser._actions:
+        if isinstance(action, (_SubParsersAction, _CountAction)):
+            pass  # Count action are not limited in number
+        elif len(action.option_strings):
+            buffer.write( "    ")
+            buffer.write( " | ".join(f'"{option}"' for option in action.option_strings))
+            buffer.write(f') arg_{action.dest}="" ;;\n')
+    buffer.write("    esac\n")
+
+    buffer.write("  done\n")
+
+    # Special case for options with associated value.
+    buffer.write('  if (( index >= 1 )); then\n')
+    buffer.write('    case ${words[$(( index - 1 ))]} in\n')
+    
+    for action in parser._actions:
+        
+        if isinstance(action, _StoreAction):
+
+            if len(action.option_strings):
+                
+                buffer.write("    ")
+                buffer.write(" | ".join(f'"{option}"' for option in action.option_strings))
+                buffer.write(")\n")
+                
+                reply = ""
+
+                if action.type == type_path:
+                    reply = '$(compgen -o plusdirs -f -- "$word")'
+                elif action.type == type_path_dir:
+                    reply = '$(compgen -o plusdirs -d -- "$word")'
+                elif action.type == type_email_or_username:
+                    pass
+                elif action.type == type_host:
+                    reply = '$(compgen -A hostname -- "$word")'
+                elif action.choices is not None:
+                    reply = f'$(compgen -W "{" ".join(action.choices)}" -- "$word")'
+
+                buffer.write(f"      COMPREPLY=({reply})\n")
+                buffer.write( "      return\n")
+                buffer.write( "      ;;\n")
+            
+    buffer.write("    esac\n")
+    buffer.write("  fi\n")
+
+    # This is the default reply for argument names.
+    buffer.write('  COMPREPLY=($(compgen -W "')
+    for cmd_name, cmd_parser in commands.items():
+        buffer.write(f"{cmd_name} ")
+    for action in parser._actions:
+        if isinstance(action, _SubParsersAction):
+            pass
+        elif len(action.option_strings):
+            buffer.write(f"$arg_{action.dest} ")
+    buffer.write('" -- "$word"))\n')
+
+    buffer.write('}\n\n')
+
+    for cmd_name, cmd_parser in commands.items():
+        gen_bash_parser_completion(cmd_parser, buffer, f"{function}_{cmd_name}")
diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py
index 43df754..5e67e72 100644
--- a/portablemc/cli/lang.py
+++ b/portablemc/cli/lang.py
@@ -33,35 +33,63 @@ def get(key: str, **kwargs) -> str:
 
 
 lang = {
-    # Addons
-    # "addon.import_error": "The addon '{addon}' has failed to build because some packages is missing:",
-    # "addon.unknown_error": "The addon '{addon}' has failed to build for unknown reason:",
     # Args root
-    "args": "A fast, reliable and cross-platform command-line Minecraft launcher and API "
-        "for developers. This launcher is compatible with the official Minecraft "
-        "launcher's version specification. It also includes fast installation of "
-        "common mod loaders such as Fabric, Forge and Quilt.",
+    "args._": 
+        "  A fast, reliable and cross-platform command-line Minecraft launcher and API\n"
+        "  for developers. Including fast and easy installation of common mod loaders such\n"
+        "  as Fabric, Forge, NeoForge and Quilt. This launcher is compatible with the\n"
+        "  standard Minecraft directories.\n\n",
     "args.main_dir": "Set the main directory where libraries, assets and versions.",
     "args.work_dir": "Set the working directory where the game run and place for examples "
         "saves, screenshots (and resources for legacy versions), it also store "
         "runtime binaries and authentication.",
     "args.timeout": "Set a global timeout (in decimal seconds) for network requests.",
     "args.output": "Set the output format of the launcher, defaults to human-color, human if not a TTY.",
-    "args.verbose": "Enable verbose output. The more -v argument you put, the more verbose the launcher will be, depending on subcommands' support (usually -v, -vv, -vvv).",
+    "args.output.comp.human-color": "Human readable output with color.",
+    "args.output.comp.human": "Human readable output.",
+    "args.output.comp.machine": "Machine readable output.",
+    "args.verbose": "Enable verbose output. The more -v argument you put, the more verbose "
+        "the launcher will be, depending on subcommands' support (usually -v, -vv, -vvv).",
     # Args common langs
     "args.common.help": "Show this help message and exit.",
     "args.common.auth_service": "Authentication service type to use for logging in the game.",
+    "args.common.auth_service.comp.microsoft": "Microsoft authentication (default).",
+    "args.common.auth_service.comp.yggdrasil": "Mojang authentication (deprecated).",
+    "args.common.auth_no_browser": "Prevent the authentication service to open your system's web browser.",
     # Args search
-    "args.search": "Search for Minecraft versions.",
+    "args.search": "Search for versions.",
+    "args.search._": 
+        "  Search for versions, by default this command will search for official Mojang version\n"
+        "  but you can change this behavior and search for local or mod loaders versions with the\n"
+        "  -k (--kind) argument. Note that the displayed table layout depends on the version kind.\n"
+        "  There is a special case when using version aliases 'release' or 'snapshot', in such case\n"
+        "  the version alias is resolved and the real version is displayed. If no filter is given,\n"
+        "  all results are displayed.\n\n"
+        "    $ portablemc search\n"
+        "    $ portablemc search release\n",
     "args.search.kind": "Select the kind of search to operate.",
+    "args.search.kind.comp.mojang": "Search for official Mojang versions (default).",
+    "args.search.kind.comp.local": "Search for locally installed versions.",
+    "args.search.kind.comp.forge": "Search for Forge versions.",
+    "args.search.kind.comp.fabric": "Search for Fabric versions.",
+    "args.search.kind.comp.quilt": "Search for Quilt versions.",
+    "args.search.input": "Search input.",
+    "args.search.input.comp.release": "Resolve version of the latest release.",
+    "args.search.input.comp.snapshot": "Resolve version of the latest snapshot.",
     # Args start
-    "args.start": "Start a Minecraft version.",
+    "args.start": "Start the game.",
     "args.start.version": "Version identifier (default to release): {formats}.",
     "args.start.version.standard": "release|snapshot|<vanilla-version>",
     "args.start.version.fabric": "fabric:[<vanilla-version>[:<loader-version>]]",
     "args.start.version.quilt": "quilt:[<vanilla-version>[:<loader-version>]]",
     "args.start.version.forge": "forge:[<forge-version>] (forge-version >= 1.5.2)",
     "args.start.version.neoforge": "neoforge:[<neoforge-version>] (neoforge-version >= 1.20.1)",
+    "args.start.version.comp.release": "Start the latest release (default).",
+    "args.start.version.comp.snapshot": "Start the latest snapshot.",
+    "args.start.version.comp.fabric": "Start Fabric mod loader with latest release.",
+    "args.start.version.comp.quilt": "Start Quilt mod loader with latest release.",
+    "args.start.version.comp.forge": "Start Forge mod loader with latest release.",
+    "args.start.version.comp.neoforge": "Start NeoForge mod loader with latest release.",
     "args.start.dry": "Simulate game starting.",
     "args.start.disable_multiplayer": "Disable the multiplayer buttons (>= 1.16).",
     "args.start.disable_chat": "Disable the online chat (>= 1.16).",
@@ -91,26 +119,44 @@ lang = {
     "args.start.auth_anonymize": "Anonymize your email or username for authentication messages.",
     "args.start.temp_login": "Flag used with -l (--login) to tell launcher not to cache your session if "
         "not already cached, disabled by default.",
-    "args.start.login": "Use a email (or deprecated username) to authenticate using selected service (with --auth-service, also overrides --username and --uuid).",
+    "args.start.login": "Use a email (or deprecated username) to authenticate using selected "
+        "service (with --auth-service, also overrides --username and --uuid).",
     "args.start.username": "Set a custom user name to play.",
     "args.start.uuid": "Set a custom user UUID to play.",
     "args.start.server": "Start the game and directly connect to a multiplayer server (>= 1.6).",
-    "args.start.server_port": "Set the server address port (given with -s, --server, >= 1.6).",
+    "args.start.server_port": "Set the server port (given with -s, --server, >= 1.6).",
     # Args login
     "args.login": "Login into your account and save the session.",
-    "args.login.microsoft": "Login using Microsoft account.",
     # Args logout
     "args.logout": "Logout and invalidate a session.",
-    "args.logout.microsoft": "Logout from a Microsoft account.",
     # Args show
-    "args.show": "Show and debug various data.",
+    "args.show": "Show, debug and generate data unrelated to the game.",
     "args.show.about": "Display authors, version and license of PortableMC.",
     "args.show.auth": "Debug the authentication database and supported services.",
     "args.show.lang": "Debug the language mappings used for messages translation.",
-    # Args addon
-    # "args.addon": "Addons management subcommands.",
-    # "args.addon.list": "List addons.",
-    # "args.addon.show": "Show an addon details.",
+    "args.show.completion": "Print a shell completion script.",
+    "args.show.completion._": 
+        # Part of this description are from 'rustup' completion description.
+        "  This command prints a shell completion script in the terminal.\n"
+        "  The installation of this completion script depends on you shell and is explained below.\n\n"
+        "  BASH:\n\n"
+        "  Completion files are commonly stored in '/etc/bash_completion.d/' for system-wide commands,\n"
+        "  but can be stored in '~/.local/share/bash-completion/completions' for user-specific commands.\n"
+        "  You can run the following commands to generate the file:\n\n"
+        "    $ mkdir -p ~/.local/share/bash-completion/completions\n"
+        "    $ portablemc show completion bash > ~/.local/share/bash-completion/completions/portablemc\n\n"
+        "  You can also dynamically evaluate the script, but it may slow your shell startup:\n\n"
+        "    $ eval \"$(portablemc show completion bash)\"\n\n"
+        "  ZSH:\n\n"
+        "  Zsh completions are commonly stored in any directory listed in your '$fpath' variable.\n"
+        "  To use these completions, you must either add the generated script to one of those\n"
+        "  directories, or add your own to this list. Once you chose a '$fpath' directory:\n\n"
+        "    $ portablemc show completion zsh > your-dir/_portablemc\n\n"
+        "  You can also dynamically evaluate a script, but it may slow your shell startup:\n\n"
+        "    $ eval \"$(portablemc show completion zsh)\"\n\n",
+    "args.show.completion.shell": "The shell to generate completion script for (default to your current shell, required if not found).",
+    "args.show.completion.shell.comp.bash": "Generate completion script for Bash.",
+    "args.show.completion.shell.comp.zsh": "Generate completion script for Zsh.",
     # Common
     "echo": "{echo}",
     "cancelled": "Cancelled.",
@@ -139,15 +185,6 @@ lang = {
     "logout.microsoft.pending": "Logging out {email} from Microsoft...",
     "logout.success": "Logged out {email}",
     "logout.unknown_session": "No session for {email}",
-    # Command addon list
-    # "addon.list.id": "ID ({count})",
-    # "addon.list.version": "Version",
-    # "addon.list.authors": "Authors",
-    # Command addon show
-    # "addon.show.not_found": "Addon '{addon}' not found.",
-    # "addon.show.version": "Version: {version}",
-    # "addon.show.authors": "Authors: {authors}",
-    # "addon.show.description": "Description: {description}",
     # Command start
     "start.version.invalid_id": "Invalid version id, expected: {expected}",
     "start.version.invalid_id_unknown_kind": "Invalid version id, unknown kind: {kind}.",
@@ -220,9 +257,11 @@ lang = {
     # Auth Yggdrasil
     "auth.yggdrasil": "Authenticating {email} with Mojang...",
     "auth.yggdrasil.enter_password": "Password: ",
+    "auth.yggdrasil.deprecated": "Mojang authentication is deprecated and does not work anymore.",
     # Auth Microsoft
     "auth.microsoft": "Authenticating {email} with Microsoft...",
-    "auth.microsoft.no_browser": "Failed to open Microsoft login page, no web browser found on your system.",
+    "auth.microsoft.no_browser_fallback": "Authenticating without local browser, please go to the following url to login:",
+    "auth.microsoft.no_browser_code": "Paste the code: ",
     "auth.microsoft.opening_browser_and_listening": "Opened authentication page in browser...",
     "auth.microsoft.close_tab_and_return": "Close this tab and return to the launcher.",
     "auth.microsoft.failed_to_authenticate": "Failed to authenticate.",
diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py
index bb8fcd9..a301a4d 100644
--- a/portablemc/cli/parse.py
+++ b/portablemc/cli/parse.py
@@ -1,6 +1,8 @@
-from argparse import ArgumentParser, HelpFormatter, ArgumentTypeError, SUPPRESS
+from argparse import ArgumentParser, Action, SUPPRESS, \
+    HelpFormatter, RawDescriptionHelpFormatter, ArgumentTypeError
 from pathlib import Path
 import sys
+import os
 
 from portablemc.standard import Context, VersionManifest
 from portablemc.auth import AuthDatabase
@@ -9,8 +11,7 @@ from .util import LibrarySpecifierFilter
 from .output import Output
 from .lang import get as _
 
-from typing import Optional, Type, Tuple, List
-
+from typing import Optional, Type, Tuple, List, Dict
 
 # The following classes are only used for type checking and represent a typed namespace
 # as produced by the arguments registered to the argument parser.
@@ -22,6 +23,7 @@ class RootNs:
     out_kind: str
     verbose: int
     # Initialized by main function after argument parsing.
+    parser: ArgumentParser
     out: Output
     context: Context
     version_manifest: VersionManifest
@@ -32,13 +34,17 @@ class SearchNs(RootNs):
     kind: str
     input: str
 
-class StartNs(RootNs):
+class AuthBaseNs(RootNs):
+    auth_service: str
+    auth_no_browser: bool
+
+class StartNs(AuthBaseNs):
     dry: bool
     disable_mp: bool
     disable_chat: bool
     demo: bool
     resolution: Optional[Tuple[int, int]]
-    jvm: Optional[str]
+    jvm: Optional[Path]
     jvm_args: Optional[str]
     no_fix: bool
     fabric_prefix: str
@@ -47,25 +53,26 @@ class StartNs(RootNs):
     neoforge_prefix: str
     lwjgl: Optional[str]
     exclude_lib: Optional[List[LibrarySpecifierFilter]]
-    include_bin: Optional[List[str]]
+    include_bin: Optional[List[Path]]
     temp_login: bool
-    login: str
     auth_service: str
     auth_anonymize: bool
+    login: Optional[str]
     username: Optional[str]
     uuid: Optional[str]
     server: Optional[str]
     server_port: Optional[int]
     version: str
 
-class LoginNs(RootNs):
-    auth_service: str
+class LoginNs(AuthBaseNs):
     email_or_username: str
 
-class LogoutNs(RootNs):
-    auth_service: str
+class LogoutNs(AuthBaseNs):
     email_or_username: str
 
+class ShowCompletionNs(RootNs):
+    shell: str
+
 
 def register_common_help(parser: ArgumentParser) -> None:
     parser.formatter_class = new_help_formatter_class(40)
@@ -73,18 +80,33 @@ def register_common_help(parser: ArgumentParser) -> None:
 
 
 def register_common_auth_service(parser: ArgumentParser) -> None:
-    parser.add_argument("--auth-service", help=_("args.common.auth_service"), default="microsoft", choices=get_auth_services())
+    
+    auth_choices = get_auth_services()
+    auth_arg = parser.add_argument("--auth-service", help=_("args.common.auth_service"), default="microsoft", choices=auth_choices)
+    for choice in auth_choices:
+        add_completion(auth_arg, choice, _(f"args.common.auth_service.comp.{choice}"))
+
+    parser.add_argument("--auth-no-browser", help=_("args.common.auth_no_browser"), action="store_true")
 
 
 def register_arguments() -> ArgumentParser:
-    parser = ArgumentParser(allow_abbrev=False, prog="portablemc", description=_("args"), add_help=False)
+
+    parser = ArgumentParser(allow_abbrev=False, prog="portablemc", description=_("args._"), add_help=False)
     register_common_help(parser)
-    parser.add_argument("--main-dir", help=_("args.main_dir"), type=Path)
-    parser.add_argument("--work-dir", help=_("args.work_dir"), type=Path)
+    
+    parser.add_argument("--main-dir", help=_("args.main_dir"), type=type_path_dir)
+    parser.add_argument("--work-dir", help=_("args.work_dir"), type=type_path_dir)
     parser.add_argument("--timeout", help=_("args.timeout"), type=float)
-    parser.add_argument("--output", help=_("args.output"), dest="out_kind", choices=get_outputs(), default="human-color" if sys.stdout.isatty() else "human")
+
+    output_choices = get_outputs()
+    output_default = "human-color" if sys.stdout.isatty() else "human"
+    output_arg = parser.add_argument("--output", help=_("args.output"), dest="out_kind", choices=output_choices, default=output_default)
+    for choice in output_choices:
+        add_completion(output_arg, choice, _(f"args.output.comp.{choice}"))
+
     parser.add_argument("-v", dest="verbose", help=_("args.verbose"), action="count", default=0)
     register_subcommands(parser.add_subparsers(title="subcommands", dest="subcommand"))
+
     return parser
 
 
@@ -94,23 +116,31 @@ def register_subcommands(subparsers) -> None:
     register_login_arguments(subparsers.add_parser("login", help=_("args.login"), add_help=False))
     register_logout_arguments(subparsers.add_parser("logout", help=_("args.logout"), add_help=False))
     register_show_arguments(subparsers.add_parser("show", help=_("args.show"), add_help=False))
-    # register_addon_arguments(subparsers.add_parser("addon", help=_("args.addon")))
 
 
 def register_search_arguments(parser: ArgumentParser) -> None:
+    
+    parser.description = _("args.search._")
     register_common_help(parser)
-    parser.add_argument("-k", "--kind", help=_("args.search.kind"), default="mojang", choices=get_search_kinds())
-    parser.add_argument("input", nargs="?")
 
+    kind_choices = get_search_kinds()
+    kind_arg = parser.add_argument("-k", "--kind", help=_("args.search.kind"), default="mojang", choices=kind_choices)
+    for choice in kind_choices:
+        add_completion(kind_arg, choice, _(f"args.search.kind.comp.{choice}"))
+
+    input_arg = parser.add_argument("input", nargs="?", help=_("args.search.input"))
+    add_completion(input_arg, "release", _("args.search.input.comp.release"))
+    add_completion(input_arg, "snapshot", _("args.search.input.comp.snapshot"))
 
 def register_start_arguments(parser: ArgumentParser) -> None:
+    
     register_common_help(parser)
     parser.add_argument("--dry", help=_("args.start.dry"), action="store_true")
     parser.add_argument("--disable-mp", help=_("args.start.disable_multiplayer"), action="store_true")
     parser.add_argument("--disable-chat", help=_("args.start.disable_chat"), action="store_true")
     parser.add_argument("--demo", help=_("args.start.demo"), action="store_true")
-    parser.add_argument("--resolution", help=_("args.start.resolution"), type=resolution_from_str)
-    parser.add_argument("--jvm", help=_("args.start.jvm"))
+    parser.add_argument("--resolution", help=_("args.start.resolution"), type=type_resolution)
+    parser.add_argument("--jvm", help=_("args.start.jvm"), type=type_path)
     parser.add_argument("--jvm-args", help=_("args.start.jvm_args"), metavar="ARGS")
     parser.add_argument("--no-fix", help=_("args.start.no_fix"), action="store_true")
     parser.add_argument("--fabric-prefix", help=_("args.start.fabric_prefix"), default="fabric", metavar="PREFIX")
@@ -119,28 +149,33 @@ def register_start_arguments(parser: ArgumentParser) -> None:
     parser.add_argument("--neoforge-prefix", help=_("args.start.neoforge_prefix"), default="neoforge", metavar="PREFIX")
     parser.add_argument("--lwjgl", help=_("args.start.lwjgl"))
     parser.add_argument("--exclude-lib", help=_("args.start.exclude_lib"), action="append", metavar="SPEC", type=LibrarySpecifierFilter.from_str)
-    parser.add_argument("--include-bin", help=_("args.start.include_bin"), action="append", metavar="PATH")
+    parser.add_argument("--include-bin", help=_("args.start.include_bin"), action="append", metavar="PATH", type=type_path)
     parser.add_argument("--auth-anonymize", help=_("args.start.auth_anonymize"), action="store_true")
     register_common_auth_service(parser)
     parser.add_argument("-t", "--temp-login", help=_("args.start.temp_login"), action="store_true")
-    parser.add_argument("-l", "--login", help=_("args.start.login"))
+    parser.add_argument("-l", "--login", help=_("args.start.login"), type=type_email_or_username)
     parser.add_argument("-u", "--username", help=_("args.start.username"), metavar="NAME")
     parser.add_argument("-i", "--uuid", help=_("args.start.uuid"))
-    parser.add_argument("-s", "--server", help=_("args.start.server"))
-    parser.add_argument("-p", "--server-port", type=int, help=_("args.start.server_port"), metavar="PORT")
-    parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge")))))
+    parser.add_argument("-s", "--server", help=_("args.start.server"), type=type_host)
+    parser.add_argument("-p", "--server-port", help=_("args.start.server_port"), metavar="PORT")
+
+    version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge")))))
+    for standard in ("release", "snapshot"):
+        add_completion(version_arg, standard, _(f"args.start.version.comp.{standard}"))
+    for loader in ("fabric", "quilt", "forge", "neoforge"):
+        add_completion(version_arg, f"{loader}:", _(f"args.start.version.comp.{loader}"))
 
 
 def register_login_arguments(parser: ArgumentParser) -> None:
     register_common_help(parser)
     register_common_auth_service(parser)
-    parser.add_argument("email_or_username")
+    parser.add_argument("email_or_username", type=type_email_or_username)
 
 
 def register_logout_arguments(parser: ArgumentParser) -> None:
     register_common_help(parser)
     register_common_auth_service(parser)
-    parser.add_argument("email_or_username")
+    parser.add_argument("email_or_username", type=type_email_or_username)
 
 
 def register_show_arguments(parser: ArgumentParser) -> None:
@@ -150,19 +185,25 @@ def register_show_arguments(parser: ArgumentParser) -> None:
     subparsers.add_parser("about", help=_("args.show.about"), add_help=False)
     subparsers.add_parser("auth", help=_("args.show.auth"), add_help=False)
     subparsers.add_parser("lang", help=_("args.show.lang"), add_help=False)
+    register_show_completion_arguments(subparsers.add_parser("completion", help=_("args.show.completion"), add_help=False))
 
 
-# def register_addon_arguments(parser: ArgumentParser):
-#     subparsers = parser.add_subparsers(title="subcommands", dest="addon_subcommand")
-#     subparsers.required = True
-#     subparsers.add_parser("list", help=_("args.addon.list"))
-#     show_parser = subparsers.add_parser("show", help=_("args.addon.show"))
-#     show_parser.add_argument("addon_id")
+def register_show_completion_arguments(parser: ArgumentParser) -> None:
+
+    parser.description = _("args.show.completion._")
+    register_common_help(parser)
+
+    # The shell argument is only required if the shell cannot be determined.
+    shell_choices = get_completion_shells()
+    shell_arg = parser.add_argument("shell", choices=shell_choices, help=_("args.show.completion.shell"))
+
+    for choice in shell_choices:
+        add_completion(shell_arg, choice, _(f"args.show.completion.shell.comp.{choice}"))
 
 
 def new_help_formatter_class(max_help_position: int) -> Type[HelpFormatter]:
 
-    class CustomHelpFormatter(HelpFormatter):
+    class CustomHelpFormatter(RawDescriptionHelpFormatter):
         def __init__(self, prog):
             super().__init__(prog, max_help_position=max_help_position)
 
@@ -172,18 +213,42 @@ def new_help_formatter_class(max_help_position: int) -> Type[HelpFormatter]:
 def get_outputs() -> List[str]:
     return ["human-color", "human", "machine"]
 
-
 def get_search_kinds() -> List[str]:
     return ["mojang", "local", "forge", "fabric", "quilt"]
 
-
 def get_auth_services() -> List[str]:
     return ["microsoft", "yggdrasil"]
 
+def get_completion_shells() -> List[str]:
+    return ["bash", "zsh"]
+
+
+def type_path(s: str) -> Path:
+    return Path(s)
 
-def resolution_from_str(s: str) -> Tuple[int, int]:
+def type_path_dir(s: str) -> Path:
+    return Path(s)
+
+def type_resolution(s: str) -> Tuple[int, int]:
     parts = s.split("x")
     if len(parts) == 2:
         return (int(parts[0]), int(parts[1]))
     else:
         raise ArgumentTypeError(_("args.start.resolution.invalid", given=s))
+
+def type_email_or_username(s: str) -> str:
+    return s
+
+def type_host(s: str) -> str:
+    return s
+
+
+def add_completion(action: Action, name: str, description: str):
+    """Add a completion for this action, this is used by 'complete' module.
+    """
+    if not hasattr(action, "_pmc_completions"):
+        action._pmc_completions = {} # type: ignore
+    action._pmc_completions[name] = description # type: ignore
+
+def get_completions(action: Action) -> Dict[str, str]:
+    return getattr(action, "_pmc_completions", {})
diff --git a/portablemc/cli/util.py b/portablemc/cli/util.py
index f4d9c65..6b2c6cd 100644
--- a/portablemc/cli/util.py
+++ b/portablemc/cli/util.py
@@ -26,7 +26,7 @@ def format_number(n: float) -> str:
     The string is at most 7 chars unless the size exceed 1 T.
     """
     if n < 1000:
-        return f"{int(n)}"
+        return f"{int(n)} "
     elif n < 1000000:
         return f"{(int(n / 100) / 10):.1f} k"
     elif n < 1000000000:
diff --git a/portablemc/forge.py b/portablemc/forge.py
index a68c89e..a90fd38 100644
--- a/portablemc/forge.py
+++ b/portablemc/forge.py
@@ -47,33 +47,32 @@ class ForgeVersion(Version):
 
         if self._forge_repo == _FORGE_REPO:
 
-            # No dash or alias version, resolve against promo version.
-            alias = self.forge_version.endswith(("-latest", "-recommended"))
-            if "-" not in self.forge_version or alias:
+            # If no alias is specified, with add recommended.
+            if "-" not in self.forge_version:
+                self.forge_version = f"{self.forge_version}-recommended"
+
+            # Now, if the specified version is an alias, we resolve it.
+            if self.forge_version.endswith(("-latest", "-recommended")):
 
-                # If it's not an alias, create the alias from the game version.
-                alias_version = self.forge_version if alias else f"{self.forge_version}-recommended"
-                watcher.handle(ForgeResolveEvent(alias_version, True, _forge_repo=_FORGE_REPO))
+                # Split the version, used later.
+                alias_version, alias = self.forge_version.rsplit("-", maxsplit=1)
+                watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_FORGE_REPO))
 
                 # Try to get loader from promo versions.
                 promo_versions = request_promo_versions()
-                loader_version = promo_versions.get(alias_version)
+                loader_version = promo_versions.get(self.forge_version)
 
-                # Try with "-latest", some version do not have recommended.
-                if loader_version is None and not alias:
-                    alias_version = f"{self.forge_version}-latest"
-                    watcher.handle(ForgeResolveEvent(alias_version, True, _forge_repo=_FORGE_REPO))
-                    loader_version = promo_versions.get(alias_version)
-                
-                # Remove alias
-                last_dash = alias_version.rindex("-")
-                alias_version = alias_version[:last_dash]
+                # If we can't find the load version, just try to other alias (issue #189).
+                if loader_version is None:
+                    alias = { "latest": "recommended", "recommended": "latest" }[alias]
+                    self.forge_version = f"{alias_version}-{alias}"
+                    watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_FORGE_REPO))
+                    loader_version = promo_versions.get(self.forge_version)
 
                 if loader_version is None:
                     raise VersionNotFoundError(f"{self.prefix}-{alias_version}-???")
 
                 self.forge_version = f"{alias_version}-{loader_version}"
-
                 watcher.handle(ForgeResolveEvent(self.forge_version, False, _forge_repo=_FORGE_REPO))
         
         elif self._forge_repo == _NEO_FORGE_REPO:
diff --git a/pyproject.toml b/pyproject.toml
index 9522ebf..a1184aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
 
 [tool.poetry]
 name = "portablemc"
-version = "4.1.0"
+version = "4.2.1"
 description = "PortableMC is a module that provides both an API for development of your custom launcher and an executable script to run PortableMC CLI."
 authors = ["Théo Rozier <contact@theorozier.fr>"]
 license = "GPL-3.0-only"
@@ -20,7 +20,10 @@ classifiers = [
 ]
 
 [tool.poetry.dependencies]
-python = ">=3.7"
+# We want to keep the Minimum Supported Python Version to the oldest non-end-of-life 
+# version. See https://devguide.python.org/versions/
+python = ">=3.8"
+certifi = { version = "*", optional = true }
 
 [tool.poetry.group.test.dependencies]
 pytest = "*"
@@ -29,3 +32,6 @@ coverage = "*"
 
 [tool.poetry.scripts]
 portablemc = "portablemc.cli:main"
+
+[tool.poetry.extras]
+certifi = ["certifi"]


Test patch:
diff --git a/test/test_cli_misc.py b/test/test_cli_misc.py
index 4312899..ad4c7d7 100644
--- a/test/test_cli_misc.py
+++ b/test/test_cli_misc.py
@@ -11,8 +11,8 @@ def test_format_number():
 
     from portablemc.cli.util import format_number, format_duration
 
-    assert format_number(0) == "0"
-    assert format_number(999) == "999"
+    assert format_number(0) == "0 "
+    assert format_number(999) == "999 "
     assert format_number(1000) == "1.0 k"
     assert format_number(999999) == "999.9 k"
     assert format_number(1000000) == "1.0 M"
@@ -35,14 +35,6 @@ def test_anonymise_email():
     assert anonymize_email("foo.bar@baz.com") == "f*****r@b*z.com"
 
 
-def test_register_arguments():
-
-    from portablemc.cli import register_arguments
-
-    # Ensure that the arguments registering successfully works.
-    register_arguments()
-
-
 def test_library_specifier_filter():
     
     from portablemc.cli.util import LibrarySpecifierFilter
@@ -78,3 +70,15 @@ def test_library_specifier_filter():
     assert not LibrarySpecifierFilter("baz", "0.2.0", "natives-windows-x86").matches(spec_classified)
     assert not LibrarySpecifierFilter("baz", "0.1.0", "windows").matches(spec)
     assert not LibrarySpecifierFilter("baz", "0.1.0", "windows").matches(spec_classified)
+
+
+def test_parser_and_completion():
+
+    from portablemc.cli.complete import gen_zsh_completion, gen_bash_completion
+    from portablemc.cli import register_arguments
+
+    # Ensure that the arguments registering successfully works.
+    args = register_arguments()
+    # Just check that it doesn't crash.
+    gen_zsh_completion(args)
+    gen_bash_completion(args)