diff --git a/.github/workflows/pyinstaller.yml b/.github/workflows/pyinstaller.yml
index ac90d373..60c539f6 100644
--- a/.github/workflows/pyinstaller.yml
+++ b/.github/workflows/pyinstaller.yml
@@ -61,10 +61,22 @@ jobs:
Take into account that `dev` releases may be unstable.
Please, use [the development release](https://github.com/soxoj/maigret/releases/tag/main) build from the **main** branch.
- Instructions:
- - Download the attached file `maigret_standalone.exe` to get the Windows executable.
- - Video guide on how to run it: https://youtu.be/qIgwTZOmMmM
- - For detailed documentation, visit: https://maigret.readthedocs.io/en/latest/
+ ## How to run
+
+ 1. Download the attached `maigret_standalone.exe`.
+ 2. Either:
+ - **Double-click it** — Maigret will ask you for a username, run a default search, and pause at the end so the output stays visible.
+ - **Run it from a terminal** for full options. Press `Win+R`, type `cmd`, hit Enter (or open PowerShell), then:
+
+ ```cmd
+ cd %USERPROFILE%\Downloads
+ maigret_standalone.exe USERNAME
+ maigret_standalone.exe USERNAME --html :: also save an HTML report
+ maigret_standalone.exe --help :: list all options
+ ```
+
+ Video guide: https://youtu.be/qIgwTZOmMmM
+ Full documentation: https://maigret.readthedocs.io/en/latest/
env:
GITHUB_TOKEN: ${{ github.token }}
diff --git a/README.md b/README.md
index dff917e4..9f5746c9 100644
--- a/README.md
+++ b/README.md
@@ -108,7 +108,19 @@ Don't want to install anything? Use the [community Telegram bot](https://sites.g
### Windows
-Download a standalone EXE from [Releases](https://github.com/soxoj/maigret/releases). Video guide: https://youtu.be/qIgwTZOmMmM.
+Download `maigret_standalone.exe` from [Releases](https://github.com/soxoj/maigret/releases). You can launch it two ways:
+
+- **Double-click it** — Maigret will ask for a username, run a default search, and wait at the end so the report links stay visible.
+- **Run it from a terminal** — open Command Prompt (press `Win+R`, type `cmd`, hit Enter) or PowerShell to pass extra options:
+
+```cmd
+cd %USERPROFILE%\Downloads
+maigret_standalone.exe USERNAME
+maigret_standalone.exe USERNAME --html :: also save an HTML report
+maigret_standalone.exe --help :: list all options
+```
+
+Video guide: https://youtu.be/qIgwTZOmMmM.
### Cloud Shells
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index 3671449e..8ada727e 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -10,9 +10,31 @@ source code of a bot is `available on GitHub `_ of GitHub repository.
+A standalone ``maigret_standalone.exe`` for Windows is published in the
+`Releases section `_ of the GitHub
+repository. A fresh build is produced automatically after each commit to the
+**main** and **dev** branches.
-Currently, the new binary is created automatically after each commit to **main** and **dev** branches.
+There are two ways to launch the EXE:
+
+* **Double-click it from Explorer.** Maigret will prompt you for a username,
+ run a default search, and pause at the end so the printed report links
+ remain on screen until you press Enter.
+* **Run it from a terminal** for full control over options:
+
+ 1. Press ``Win+R``, type ``cmd``, and hit Enter (or use PowerShell).
+ 2. Change to the folder where you saved the file, e.g.
+ ``cd %USERPROFILE%\Downloads``.
+ 3. Run it with at least one username:
+
+ .. code-block:: bat
+
+ maigret_standalone.exe USERNAME
+ maigret_standalone.exe USERNAME --html :: also save an HTML report
+ maigret_standalone.exe USERNAME --pdf :: also save a PDF report
+ maigret_standalone.exe --help :: list all options
+
+Reports are written next to the EXE in a ``reports\`` subfolder.
Video guide on how to run it: https://youtu.be/qIgwTZOmMmM.
diff --git a/maigret/resources/db_meta.json b/maigret/resources/db_meta.json
index f11e9300..b43e547a 100644
--- a/maigret/resources/db_meta.json
+++ b/maigret/resources/db_meta.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updated_at": "2026-05-20T21:53:00Z",
+ "updated_at": "2026-05-21T10:10:02Z",
"sites_count": 3158,
"min_maigret_version": "0.6.1",
"data_sha256": "15283c81e9f18cd07c674a554bbfd0994f18c12c20afd5655d42ba70d1403caa",
diff --git a/pyinstaller/maigret_standalone.py b/pyinstaller/maigret_standalone.py
index 5b83b447..3470d33e 100755
--- a/pyinstaller/maigret_standalone.py
+++ b/pyinstaller/maigret_standalone.py
@@ -1,7 +1,77 @@
#!/usr/bin/env python3
import asyncio
+import platform
+import sys
import maigret
+
+DOUBLE_CLICK_INTRO = """\
+Maigret runs from the command line. You can:
+
+ - call it from cmd/PowerShell: maigret_standalone.exe USERNAME [options]
+ - or enter a username below for a default search.
+
+Full options: maigret_standalone.exe --help
+Documentation: https://maigret.readthedocs.io/
+"""
+
+
+def _launched_by_double_click() -> bool:
+ # On Windows, Explorer spawns a fresh console just for our process, so
+ # GetConsoleProcessList returns 1. When launched from an existing cmd /
+ # PowerShell session the count is >= 2 (the shell is attached too).
+ if platform.system() != "Windows":
+ return False
+ if len(sys.argv) > 1:
+ return False
+ try:
+ import ctypes
+
+ buf = (ctypes.c_uint * 4)()
+ count = ctypes.windll.kernel32.GetConsoleProcessList(buf, 4)
+ return count <= 1
+ except Exception:
+ return False
+
+
+def _pause() -> None:
+ try:
+ input("\nPress Enter to exit...")
+ except EOFError:
+ pass
+
+
+def _prompt_for_username() -> str:
+ try:
+ return input("Username to search: ").strip()
+ except (EOFError, KeyboardInterrupt):
+ return ""
+
+
+def main() -> None:
+ if not _launched_by_double_click():
+ asyncio.run(maigret.cli())
+ return
+
+ print(DOUBLE_CLICK_INTRO)
+ username = _prompt_for_username()
+
+ if not username:
+ print(
+ "\nNo username entered. Re-run with one, e.g.\n"
+ " maigret_standalone.exe alice"
+ )
+ _pause()
+ return
+
+ # Inject the username so maigret's argparse sees it like a normal CLI run.
+ sys.argv = [sys.argv[0], username]
+ try:
+ asyncio.run(maigret.cli())
+ finally:
+ _pause()
+
+
if __name__ == "__main__":
- asyncio.run(maigret.cli())
\ No newline at end of file
+ main()
diff --git a/tests/test_standalone_wrapper.py b/tests/test_standalone_wrapper.py
new file mode 100644
index 00000000..bdc6798e
--- /dev/null
+++ b/tests/test_standalone_wrapper.py
@@ -0,0 +1,149 @@
+"""Unit tests for the PyInstaller Windows wrapper.
+
+The wrapper at ``pyinstaller/maigret_standalone.py`` lives outside the
+``maigret`` package, so we load it by path.
+"""
+import importlib.util
+import os
+import sys
+from unittest import mock
+
+import pytest
+
+
+WRAPPER_PATH = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
+ "pyinstaller",
+ "maigret_standalone.py",
+)
+
+
+@pytest.fixture
+def wrapper():
+ spec = importlib.util.spec_from_file_location(
+ "maigret_standalone_under_test", WRAPPER_PATH
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_returns_false_on_non_windows(wrapper):
+ with mock.patch.object(wrapper.platform, "system", return_value="Darwin"):
+ assert wrapper._launched_by_double_click() is False
+
+
+def test_returns_false_when_username_argument_present(wrapper):
+ # Extra argv => user invoked from a shell with a username; never a double-click.
+ with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe", "alice"]):
+ assert wrapper._launched_by_double_click() is False
+
+
+def test_double_click_detected_when_only_our_process_attached(wrapper):
+ fake_ctypes = mock.MagicMock()
+ fake_ctypes.windll.kernel32.GetConsoleProcessList.return_value = 1
+
+ with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \
+ mock.patch.dict(sys.modules, {"ctypes": fake_ctypes}):
+ assert wrapper._launched_by_double_click() is True
+
+
+def test_cmd_invocation_detected_when_shell_also_attached(wrapper):
+ fake_ctypes = mock.MagicMock()
+ fake_ctypes.windll.kernel32.GetConsoleProcessList.return_value = 2
+
+ with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \
+ mock.patch.dict(sys.modules, {"ctypes": fake_ctypes}):
+ assert wrapper._launched_by_double_click() is False
+
+
+def test_returns_false_when_console_api_raises(wrapper):
+ fake_ctypes = mock.MagicMock()
+ fake_ctypes.windll.kernel32.GetConsoleProcessList.side_effect = OSError("boom")
+
+ with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \
+ mock.patch.dict(sys.modules, {"ctypes": fake_ctypes}):
+ assert wrapper._launched_by_double_click() is False
+
+
+def test_main_runs_cli_when_not_double_click(wrapper):
+ sentinel = object()
+ with mock.patch.object(wrapper, "_launched_by_double_click", return_value=False), \
+ mock.patch.object(wrapper.asyncio, "run") as run_mock, \
+ mock.patch.object(wrapper.maigret, "cli", new=mock.Mock(return_value=sentinel)) as cli_mock:
+ wrapper.main()
+
+ cli_mock.assert_called_once_with()
+ run_mock.assert_called_once_with(sentinel)
+
+
+def test_main_prompts_and_runs_search_on_double_click(wrapper):
+ sentinel = object()
+ original_argv = ["maigret_standalone.exe"]
+ inputs = iter(["alice", ""]) # username prompt, then pause prompt
+
+ with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \
+ mock.patch.object(wrapper.sys, "argv", list(original_argv)), \
+ mock.patch("builtins.input", side_effect=lambda *_: next(inputs)) as input_mock, \
+ mock.patch.object(wrapper.asyncio, "run") as run_mock, \
+ mock.patch.object(wrapper.maigret, "cli", new=mock.Mock(return_value=sentinel)):
+ wrapper.main()
+ # argv was rewritten to feed argparse the entered username.
+ assert wrapper.sys.argv == ["maigret_standalone.exe", "alice"]
+
+ run_mock.assert_called_once_with(sentinel)
+ assert input_mock.call_count == 2 # username prompt + final pause
+
+
+def test_main_skips_search_on_empty_username(wrapper):
+ inputs = iter(["", ""]) # empty username, then pause prompt
+
+ with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \
+ mock.patch("builtins.input", side_effect=lambda *_: next(inputs)) as input_mock, \
+ mock.patch.object(wrapper.asyncio, "run") as run_mock, \
+ mock.patch.object(wrapper.maigret, "cli") as cli_mock:
+ wrapper.main()
+
+ cli_mock.assert_not_called()
+ run_mock.assert_not_called()
+ assert input_mock.call_count == 2 # asked for username, then paused
+
+
+def test_main_pauses_even_when_cli_raises_system_exit(wrapper):
+ inputs = iter(["alice", ""])
+
+ with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \
+ mock.patch("builtins.input", side_effect=lambda *_: next(inputs)) as input_mock, \
+ mock.patch.object(wrapper.asyncio, "run", side_effect=SystemExit(2)), \
+ mock.patch.object(wrapper.maigret, "cli", new=mock.Mock()):
+ with pytest.raises(SystemExit):
+ wrapper.main()
+
+ # Both inputs should have fired: username prompt + final pause via finally.
+ assert input_mock.call_count == 2
+
+
+def test_main_treats_keyboard_interrupt_at_prompt_as_empty(wrapper):
+ def fake_input(*_args, **_kwargs):
+ if fake_input.calls == 0:
+ fake_input.calls += 1
+ raise KeyboardInterrupt
+ fake_input.calls += 1
+ return ""
+ fake_input.calls = 0
+
+ with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \
+ mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \
+ mock.patch("builtins.input", side_effect=fake_input), \
+ mock.patch.object(wrapper.asyncio, "run") as run_mock, \
+ mock.patch.object(wrapper.maigret, "cli") as cli_mock:
+ wrapper.main()
+
+ cli_mock.assert_not_called()
+ run_mock.assert_not_called()