feat: small command line helpers for quick tests (#4272)

* feat: small command line helpers for quick tests

Add --cflags, --ldflags, --embed, and --file to the pybind11 command
line tool, based on python-config. This makes quick one-off compiles
easy:

    c++ $(python3 -m pybind11 --file=example.cpp)

Assisted-by: ClaudeCode:claude-fable-5

* refactor: quote paths, clarify CLI output logic, strengthen tests

- Quote include and library dirs in get_cflags/get_ldflags so paths with
  spaces work, sharing one quote helper in commands.py
- Restructure the --file/--cflags/--ldflags printing into a single print
- Note the Unix-compiler orientation in help text and docs
- Test -L presence for --embed and quoting of paths with spaces

Assisted-by: ClaudeCode:claude-fable-5

* refactor: simplify flag helpers and keep import pybind11 light

- Replace _get_config_var(name, fmt, quote) with a plain _config(name)
  string helper; formatting and quoting happen at the call sites
- Defer sysconfig/shlex imports so import pybind11 does not pay for the
  CLI (~1ms -> ~0.08ms for pybind11.commands)
- Fetch EXT_SUFFIX once in main()
- Import commands normally in the quoting test instead of importlib
  file loading

Assisted-by: ClaudeCode:claude-fable-5

* fix: shared-library flags on all Unix platforms

The --file/--ldflags output only added -shared and -fPIC on Linux, so
other Unix platforms (FreeBSD, Solaris, AIX) linked an executable and
failed on the missing main. Gate on os.name instead.

Assisted-by: ClaudeCode:claude-opus-5
diff --git a/docs/compiling.rst b/docs/compiling.rst
index b693bd5..1838777 100644
--- a/docs/compiling.rst
+++ b/docs/compiling.rst
@@ -665,6 +665,25 @@
 
     $ c++ -O3 -Wall -shared -std=c++11 -undefined dynamic_lookup $(python3 -m pybind11 --includes) example.cpp -o example$(python3-config --extension-suffix)
 
+For quick tests, the command line tool can also produce the full set of flags
+for you, based on ``python-config``:
+
+.. code-block:: bash
+
+    $ c++ $(python3 -m pybind11 --cflags) example.cpp $(python3 -m pybind11 --ldflags) -o example$(python3 -m pybind11 --extension-suffix)
+
+Or, shorter still, ``--file`` prints everything after the compiler for a given
+source file, including the output name (add ``--embed`` for a program that
+embeds the interpreter instead of an extension):
+
+.. code-block:: bash
+
+    $ c++ $(python3 -m pybind11 --file=example.cpp)
+
+These helpers target Unix-style compilers (GCC/Clang) and are intended for
+quick tests, not production builds; ``--file`` places the output next to the
+source file.
+
 In general, it is advisable to include several additional build parameters
 that can considerably reduce the size of the created binary. Refer to section
 :ref:`cmake` for a detailed example of a suitable cross-platform CMake-based
diff --git a/pybind11/__main__.py b/pybind11/__main__.py
index 21c3cd9..ce597c7 100644
--- a/pybind11/__main__.py
+++ b/pybind11/__main__.py
@@ -3,50 +3,25 @@
 
 import argparse
 import functools
-import re
 import sys
 import sysconfig
+from pathlib import Path
 
 from ._version import __version__
-from .commands import get_cmake_dir, get_include, get_pkgconfig_dir
-
-# This is the conditional used for os.path being posixpath
-if "posix" in sys.builtin_module_names:
-    from shlex import quote
-elif "nt" in sys.builtin_module_names:
-    # See https://github.com/mesonbuild/meson/blob/db22551ed9d2dd7889abea01cc1c7bba02bf1c75/mesonbuild/utils/universal.py#L1092-L1121
-    # and the original documents:
-    # https://docs.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments and
-    # https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
-    UNSAFE = re.compile("[ \t\n\r]")
-
-    def quote(s: str) -> str:
-        if s and not UNSAFE.search(s):
-            return s
-
-        # Paths cannot contain a '"' on Windows, so we don't need to worry
-        # about nuanced counting here.
-        return f'"{s}\\"' if s.endswith("\\") else f'"{s}"'
-else:
-
-    def quote(s: str) -> str:
-        return s
+from .commands import (
+    _quote as quote,
+)
+from .commands import (
+    get_cflags,
+    get_cmake_dir,
+    get_include_dirs,
+    get_ldflags,
+    get_pkgconfig_dir,
+)
 
 
 def print_includes() -> None:
-    dirs = [
-        sysconfig.get_path("include"),
-        sysconfig.get_path("platinclude"),
-        get_include(),
-    ]
-
-    # Make unique but preserve order
-    unique_dirs = []
-    for d in dirs:
-        if d and d not in unique_dirs:
-            unique_dirs.append(d)
-
-    print(" ".join(quote(f"-I{d}") for d in unique_dirs))
+    print(" ".join(quote(f"-I{d}") for d in get_include_dirs()))
 
 
 def main() -> None:
@@ -80,17 +55,54 @@
         action="store_true",
         help="Print the extension for a Python module",
     )
+    parser.add_argument(
+        "--cflags",
+        action="store_true",
+        help="Print the compile flags for a simple extension (Unix-style compilers).",
+    )
+    parser.add_argument(
+        "--ldflags",
+        action="store_true",
+        help="Print the link flags for a simple extension (Unix-style compilers).",
+    )
+    parser.add_argument(
+        "--embed",
+        action="store_true",
+        help="Build for embedding instead of an extension; affects --ldflags and --file.",
+    )
+    parser.add_argument(
+        "--file",
+        type=Path,
+        help="Print a full command-line suffix for compiling the given file;"
+        " the output goes next to the source file (Unix-style compilers).",
+    )
     args = parser.parse_args()
     if not sys.argv[1:]:
         parser.print_help()
-    if args.includes:
+    ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") or ""
+    if args.file:
+        suffix = "" if args.embed else ext_suffix
+        print(
+            get_cflags(),
+            quote(str(args.file)),
+            get_ldflags(embed=args.embed),
+            "-o",
+            quote(str(args.file.with_suffix(suffix))),
+        )
+    else:
+        if args.cflags:
+            print(get_cflags())
+        if args.ldflags:
+            print(get_ldflags(embed=args.embed))
+    # --cflags and --file already contain the include flags
+    if args.includes and not (args.cflags or args.file):
         print_includes()
     if args.cmakedir:
         print(quote(get_cmake_dir()))
     if args.pkgconfigdir:
         print(quote(get_pkgconfig_dir()))
     if args.extension_suffix:
-        print(sysconfig.get_config_var("EXT_SUFFIX"))
+        print(ext_suffix)
 
 
 if __name__ == "__main__":
diff --git a/pybind11/commands.py b/pybind11/commands.py
index d535b6c..8bd0a9b 100644
--- a/pybind11/commands.py
+++ b/pybind11/commands.py
@@ -1,9 +1,46 @@
 from __future__ import annotations
 
 import os
+import re
+import sys
 
 DIR = os.path.abspath(os.path.dirname(__file__))
 
+# shlex/sysconfig are imported lazily below to keep `import pybind11` light
+
+# This is the conditional used for os.path being posixpath
+if "posix" in sys.builtin_module_names:
+
+    def _quote(s: str) -> str:
+        import shlex
+
+        return shlex.quote(s)
+elif "nt" in sys.builtin_module_names:
+    # See https://github.com/mesonbuild/meson/blob/db22551ed9d2dd7889abea01cc1c7bba02bf1c75/mesonbuild/utils/universal.py#L1092-L1121
+    # and the original documents:
+    # https://docs.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments and
+    # https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
+    _UNSAFE = re.compile("[ \t\n\r]")
+
+    def _quote(s: str) -> str:
+        if s and not _UNSAFE.search(s):
+            return s
+
+        # Paths cannot contain a '"' on Windows, so we don't need to worry
+        # about nuanced counting here.
+        return f'"{s}\\"' if s.endswith("\\") else f'"{s}"'
+else:
+
+    def _quote(s: str) -> str:
+        return s
+
+
+def _config(name: str) -> str:
+    """A stripped sysconfig variable, or "" if unset."""
+    import sysconfig
+
+    return str(sysconfig.get_config_var(name) or "").strip()
+
 
 def get_include(user: bool = False) -> str:  # noqa: ARG001
     """
@@ -37,3 +74,67 @@
 
     msg = "pybind11 not installed, installation required to access the pkgconfig files"
     raise ImportError(msg)
+
+
+def get_include_dirs() -> list[str]:
+    """
+    Return the unique include directories for Python and pybind11.
+    """
+    import sysconfig
+
+    dirs = [
+        sysconfig.get_path("include"),
+        sysconfig.get_path("platinclude"),
+        get_include(),
+    ]
+
+    # Make unique but preserve order
+    unique_dirs = []
+    for d in dirs:
+        if d and d not in unique_dirs:
+            unique_dirs.append(d)
+    return unique_dirs
+
+
+def get_cflags() -> str:
+    """
+    Return the compile flags for building a simple extension with a
+    Unix-style command-line compiler (GCC/Clang). Based on python-config.
+    """
+    flags = [_quote(f"-I{d}") for d in get_include_dirs()]
+    # CFLAGS is a pre-composed multi-flag string, passed through as-is (like
+    # python-config does)
+    if cflags := _config("CFLAGS"):
+        flags.append(cflags)
+    flags.append("-std=c++17")
+    return " ".join(flags)
+
+
+def get_ldflags(embed: bool = False) -> str:
+    """
+    Return the link flags for building a simple extension (or, with
+    embed=True, an embedding program) with a Unix-style command-line
+    compiler (GCC/Clang). Based on python-config.
+    """
+    # LDFLAGS/LIBS/SYSLIBS are pre-composed multi-flag strings, passed
+    # through as-is (like python-config does)
+    flags = [ldflags] if (ldflags := _config("LDFLAGS")) else []
+
+    if embed:
+        if libdir := _config("LIBDIR"):
+            flags.append(_quote(f"-L{libdir}"))
+        if not _config("Py_ENABLE_SHARED") and (libpl := _config("LIBPL")):
+            flags.append(_quote(f"-L{libpl}"))
+        abiflags = getattr(sys, "abiflags", "") or ""
+        flags.append(f"-lpython{_config('VERSION')}{abiflags}")
+        if libs := _config("LIBS"):
+            flags.append(libs)
+        if syslibs := _config("SYSLIBS"):
+            flags.append(syslibs)
+    elif sys.platform.startswith("darwin"):
+        flags += ["-undefined", "dynamic_lookup", "-shared"]
+    elif os.name == "posix":
+        # Linux and other Unix platforms (FreeBSD, Solaris, AIX, ...)
+        flags += ["-fPIC", "-shared"]
+
+    return " ".join(flags)
diff --git a/tests/extra_python_package/test_files.py b/tests/extra_python_package/test_files.py
index d96e9af..0abb48c 100644
--- a/tests/extra_python_package/test_files.py
+++ b/tests/extra_python_package/test_files.py
@@ -1,16 +1,20 @@
 from __future__ import annotations
 
 import contextlib
+import importlib
 import os
 import re
 import shutil
 import subprocess
 import sys
+import sysconfig
 import tarfile
 import zipfile
 from pathlib import Path
 from typing import Generator
 
+import pytest
+
 # These tests must be run explicitly
 
 DIR = Path(__file__).parent.resolve()
@@ -384,3 +388,63 @@
         expected_patch = f"{micro}{level_str}{release_serial}"
 
     assert patch == expected_patch
+
+
+def run_command_line(*args: str) -> str:
+    env = os.environ.copy()
+    env["PYTHONPATH"] = str(MAIN_DIR)
+    result = subprocess.run(
+        [sys.executable, "-m", "pybind11", *args],
+        capture_output=True,
+        text=True,
+        check=True,
+        env=env,
+    )
+    return result.stdout
+
+
+def test_cli_cflags():
+    out = run_command_line("--cflags")
+    assert "-std=c++17" in out
+    assert f"-I{sysconfig.get_path('include')}" in out
+
+
+def test_cli_ldflags_embed():
+    out = run_command_line("--ldflags", "--embed")
+    assert "-lpython" in out
+    if sysconfig.get_config_var("LIBDIR"):
+        assert "-L" in out
+
+
+@pytest.mark.skipif(os.name != "posix", reason="quote style is platform-specific")
+def test_cflags_quotes_paths_with_spaces(monkeypatch):
+    monkeypatch.syspath_prepend(str(MAIN_DIR))
+    commands = importlib.import_module("pybind11.commands")
+    monkeypatch.setattr(sysconfig, "get_path", lambda name: f"/spa ced/{name}")
+    assert "'-I/spa ced/include'" in commands.get_cflags()
+
+
+@pytest.mark.skipif(os.name != "posix", reason="Unix link flags only")
+def test_ldflags_other_unix(monkeypatch):
+    monkeypatch.syspath_prepend(str(MAIN_DIR))
+    commands = importlib.import_module("pybind11.commands")
+    monkeypatch.setattr(sys, "platform", "freebsd14")
+    monkeypatch.setattr(commands, "_config", lambda name: "")  # noqa: ARG005
+    out = commands.get_ldflags()
+    assert "-shared" in out
+    assert "-fPIC" in out
+
+
+def test_cli_file():
+    out = run_command_line("--file", "example.cpp").rstrip()
+    ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
+    assert "-std=c++17" in out
+    assert out.index("-std=c++17") < out.index("example.cpp")
+    assert out.endswith(f"-o example{ext_suffix}")
+    if sys.platform.startswith(("linux", "darwin")):
+        assert out.index("example.cpp") < out.index("-shared")
+
+
+def test_cli_file_embed():
+    out = run_command_line("--file", "example.cpp", "--embed").rstrip()
+    assert out.endswith("-o example")