diff --git a/pyproject.toml b/pyproject.toml
index 2989a8232ba8468612c1460f16fe4924e1b07085..cb1368981115ec3261b160265974e51b8f71eb73 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,7 @@ testing = {file = [
 
 [project.entry-points."swh.cli.subcommands"]
 "swh.core.db" = "swh.core.cli.db"
+"swh.core.backend" = "swh.core.cli.backend"
 
 [project.entry-points.pytest11]
 "pytest_swh_core" = "swh.core.pytest_plugin"
diff --git a/swh/core/cli/backend.py b/swh/core/cli/backend.py
new file mode 100755
index 0000000000000000000000000000000000000000..55d289e2ba6d0d259d00fd848fccd00ee3f1cb77
--- /dev/null
+++ b/swh/core/cli/backend.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+# Copyright (C) 2024  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+import logging
+from os import get_terminal_size
+
+import click
+
+from swh.core.cli import CONTEXT_SETTINGS
+from swh.core.cli import swh as swh_cli_group
+
+logger = logging.getLogger(__name__)
+
+
+@swh_cli_group.group(name="backend", context_settings=CONTEXT_SETTINGS)
+@click.pass_context
+def backend(ctx):
+    """Software Heritage backend generic tools."""
+    pass
+
+
+@backend.command(name="list", context_settings=CONTEXT_SETTINGS)
+@click.argument("package", required=True)
+@click.argument("cls", required=False, default=None)
+@click.pass_context
+def bk_list(ctx, package, cls):
+    """Show registered backends for the given package
+
+    With their documentation, if any. Example::
+
+        \b
+        $ swh backend list vault
+
+        memory     Stub vault backend, for use in the CLI.
+        postgresql Backend for the Software Heritage Vault.
+        remote     Client to the Software Heritage vault cache.
+
+    If 'cls' is given, display the full docstring for the corresponding
+    backend.
+
+    Example::
+
+        \b
+        $ swh backend list vault memory
+
+        vault:memory
+
+        Stub vault backend, for use in the CLI.
+
+    """
+    from swh.core.config import get_swh_backend_module, list_swh_backends
+
+    if cls is None:
+        items = []
+        for backend in list_swh_backends(package):
+            _, BackendCls = get_swh_backend_module(package, backend)
+            msg = BackendCls.__doc__
+            if msg is None:
+                msg = ""
+            msg = msg.strip()
+            if "\n" in msg:
+                firstline = msg.splitlines()[0]
+            else:
+                firstline = msg
+            items.append((backend, firstline, msg))
+        if not items:
+            click.secho(
+                f"No backend found for package '{package}'",
+                fg="red",
+                bold=True,
+                err=True,
+            )
+            raise click.Abort()
+
+        max_name = max(len(name) for name, _, _ in items)
+        try:
+            width = get_terminal_size().columns
+        except OSError:
+            width = 78
+        for name, firstline, msg in items:
+            click.echo(
+                click.style(
+                    f"{name:<{max_name + 1}}",
+                    fg="green",
+                    bold=True,
+                ),
+                nl=False,
+            )
+            firstline = firstline[: width - max_name - 1]
+            click.echo(firstline)
+    else:
+        try:
+            _, BackendCls = get_swh_backend_module(package, cls)
+        except ValueError:
+            BackendCls = None
+
+        if BackendCls is None:
+            click.secho(
+                f"No backend '{cls}' found for package '{package}'",
+                fg="red",
+                bold=True,
+                err=True,
+            )
+            raise click.Abort()
+
+        click.echo(
+            click.style(
+                package,
+                fg="green",
+                bold=True,
+            )
+            + ":"
+            + click.style(
+                cls,
+                fg="yellow",
+                bold=True,
+            )
+            + "\n",
+        )
+        click.echo(BackendCls.__doc__.strip())
diff --git a/swh/core/config.py b/swh/core/config.py
index c895d323c32c7bfe59c54fb5023dbd7550ec4cb3..16f3590cf9d7b7450d30465c6a56673aa331f6a8 100644
--- a/swh/core/config.py
+++ b/swh/core/config.py
@@ -373,6 +373,13 @@ def get_swh_backend_from_fullmodule(
     return None, None
 
 
+def list_swh_backends(package: str) -> List[str]:
+    if package.startswith("swh."):
+        package = package[4:]
+    entry_points = get_entry_points(group=f"swh.{package}.classes")
+    return [ep.name for ep in entry_points]
+
+
 def list_db_config_entries(cfg) -> Generator[Tuple[str, str, dict, str], None, None]:
     """List all the db config entries in the given config structure
 
diff --git a/swh/core/db/tests/conftest.py b/swh/core/db/tests/conftest.py
index d99bf6921d48b7f6c1223d0437606ed0472fbe41..1b51223e7ef9433bd2385da7b1ecbbf83c107e09 100644
--- a/swh/core/db/tests/conftest.py
+++ b/swh/core/db/tests/conftest.py
@@ -3,9 +3,7 @@
 # License: GNU General Public License version 3, or any later version
 # See top-level LICENSE file for more information
 
-from importlib import import_module
 import os
-from pathlib import Path
 
 from click.testing import CliRunner
 from hypothesis import HealthCheck
@@ -38,67 +36,3 @@ postgresql_proc = factories.postgresql_proc(
 @pytest.fixture
 def cli_runner():
     return CliRunner()
-
-
-@pytest.fixture()
-def mock_import_module(request, mocker, datadir):
-    mock = mocker.MagicMock
-
-    def import_module_mocker(name, package=None):
-        if not name.startswith("swh.test"):
-            return import_module(name, package)
-
-        m = request.node.get_closest_marker("init_version")
-        if m:
-            version = m.kwargs.get("version", 1)
-        else:
-            version = 3
-        if name.startswith("swh."):
-            name = name[4:]
-        modpath = name.split(".")
-
-        def get_datastore(*args, **kw):
-            return mock(current_version=version)
-
-        return mock(
-            __name__=name.split(".")[-1],
-            __file__=os.path.join(datadir, *modpath, "__init__.py"),
-            get_datastore=get_datastore,
-        )
-
-    return mocker.patch("swh.core.db.db_utils.import_module", import_module_mocker)
-
-
-@pytest.fixture()
-def mock_get_entry_points(request, mocker, datadir, mock_import_module):
-    mock = mocker.MagicMock
-
-    def get_entry_points_mocker(group):
-        m = request.node.get_closest_marker("init_version")
-        if m:
-            version = m.kwargs.get("version", 1)
-        else:
-            version = 3
-
-        class EntryPoints(dict):
-            def __iter__(self):
-                return iter(self.values())
-
-        entrypoints = EntryPoints()
-        for entry in (Path(datadir) / "test").iterdir():
-            if entry.is_dir():
-                ep = mock(
-                    module=f"swh.test.{entry.name}",
-                    load=lambda: mock(current_version=version),
-                )
-                # needed to overwrite the Mock's name argument, see
-                # https://docs.python.org/3/library/unittest.mock.html#mock-names-and-the-name-attribute
-                ep.name = entry.name
-                entrypoints[entry.name] = ep
-        return entrypoints
-
-    return mocker.patch("swh.core.config.get_entry_points", get_entry_points_mocker)
-
-
-# for bw compat
-mock_get_swh_backend_module = mock_get_entry_points
diff --git a/swh/core/pytest_plugin.py b/swh/core/pytest_plugin.py
index 9503c8222e589dd456298e9b5274ae5584d006ce..7171d815f8996bc2398bb5a4ac0c62060ced75cf 100644
--- a/swh/core/pytest_plugin.py
+++ b/swh/core/pytest_plugin.py
@@ -5,8 +5,10 @@
 
 from collections import deque
 from functools import partial
+from importlib import import_module
 import logging
 from os import path
+from pathlib import Path
 import re
 from typing import Dict, List, Optional
 from urllib.parse import unquote, urlparse
@@ -418,3 +420,73 @@ def clean_scopes():
     scope._global_scope = None
     scope._isolation_scope.set(None)
     scope._current_scope.set(None)
+
+
+@pytest.fixture()
+def mock_import_module(request, mocker, datadir):
+    mock = mocker.MagicMock
+
+    def import_module_mocker(name, package=None):
+        if not name.startswith("swh.test"):
+            return import_module(name, package)
+
+        m = request.node.get_closest_marker("init_version")
+        if m:
+            version = m.kwargs.get("version", 1)
+        else:
+            version = 3
+        if name.startswith("swh."):
+            name = name[4:]
+        modpath = name.split(".")
+
+        def get_datastore(*args, **kw):
+            return mock(current_version=version)
+
+        return mock(
+            __name__=name.split(".")[-1],
+            __file__=str(Path(datadir, *modpath, "__init__.py")),
+            get_datastore=get_datastore,
+        )
+
+    return mocker.patch("swh.core.db.db_utils.import_module", import_module_mocker)
+
+
+@pytest.fixture()
+def mock_get_entry_points(request, mocker, datadir, mock_import_module):
+    mock = mocker.MagicMock
+
+    def get_entry_points_mocker(group):
+        m = request.node.get_closest_marker("init_version")
+        if m:
+            version = m.kwargs.get("version", 1)
+        else:
+            version = 3
+
+        class EntryPoints(dict):
+            def __iter__(self):
+                return iter(self.values())
+
+        package = group[4:-8]  # remove 'swh.' and '.classes'
+        entrypoints = EntryPoints()
+        pkgdir = Path(datadir) / package
+        if pkgdir.is_dir():
+            for entry in pkgdir.iterdir():
+                if not entry.name.startswith("_") and entry.is_dir():
+                    ep = mock(
+                        module=f"swh.{package}.{entry.name}",
+                        load=lambda: mock(
+                            current_version=version,
+                            __doc__="A mockup backend for tests",
+                        ),
+                    )
+                    # needed to overwrite the Mock's name argument, see
+                    # https://docs.python.org/3/library/unittest.mock.html#mock-names-and-the-name-attribute
+                    ep.name = entry.name
+                    entrypoints[entry.name] = ep
+        return entrypoints
+
+    return mocker.patch("swh.core.config.get_entry_points", get_entry_points_mocker)
+
+
+# for bw compat
+mock_get_swh_backend_module = mock_get_entry_points
diff --git a/swh/core/tests/data/test/__init__.py b/swh/core/tests/data/test/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/swh/core/tests/data/test/__toto__/.placeholder b/swh/core/tests/data/test/__toto__/.placeholder
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/swh/core/tests/data/test/backend1/__init__.py b/swh/core/tests/data/test/backend1/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/swh/core/tests/test_cli_backend.py b/swh/core/tests/test_cli_backend.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e5e449cac699d40328bd00c24df7976288e55ae
--- /dev/null
+++ b/swh/core/tests/test_cli_backend.py
@@ -0,0 +1,56 @@
+# Copyright (C) 2024  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+
+from click.testing import CliRunner
+import pytest
+
+from swh.core.tests.test_cli import assert_result
+
+
+@pytest.fixture
+def swhmain(swhmain):
+    from swh.core.cli.backend import backend as swhbackend
+
+    swhmain.add_command(swhbackend)
+    return swhmain
+
+
+def test_backend_list_ok(swhmain, mock_get_entry_points):
+    runner = CliRunner()
+    result = runner.invoke(swhmain, ["backend", "list", "test"])
+    assert_result(result)
+    assert result.output.strip() == "backend1 A mockup backend for tests"
+
+
+def test_backend_list_empty(swhmain, mock_get_entry_points):
+    runner = CliRunner()
+    result = runner.invoke(swhmain, ["backend", "list", "wrong_package"])
+    assert result.exit_code == 1
+    assert "No backend found for package 'wrong_package'" in result.output.strip()
+
+
+def test_backend_list_cls_ok(swhmain, mock_get_entry_points):
+    runner = CliRunner()
+    result = runner.invoke(swhmain, ["backend", "list", "test", "backend1"])
+    assert_result(result)
+    assert result.output.strip() == "test:backend1\n\nA mockup backend for tests"
+
+
+def test_backend_list_cls_no_package(swhmain, mock_get_entry_points):
+    runner = CliRunner()
+    result = runner.invoke(swhmain, ["backend", "list", "wrong_package", "backend1"])
+    assert result.exit_code == 1
+    assert (
+        "No backend 'backend1' found for package 'wrong_package'"
+        in result.output.strip()
+    )
+
+
+def test_backend_list_cls_no_backend(swhmain, mock_get_entry_points):
+    runner = CliRunner()
+    result = runner.invoke(swhmain, ["backend", "list", "test", "backend2"])
+    assert result.exit_code == 1
+    assert "No backend 'backend2' found for package 'test'" in result.output.strip()