changeset 4075:47401850dec6

refactoring: rename `libervia.frontends.jp` to `libervia.cli`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:54:26 +0200
parents 26b7ed2817da
children b620a8e882e1
files libervia/backend/core/launcher.py libervia/backend/plugins/plugin_blog_import_dokuwiki.py libervia/backend/tools/common/template.py libervia/cli/__init__.py libervia/cli/arg_tools.py libervia/cli/base.py libervia/cli/cmd_account.py libervia/cli/cmd_adhoc.py libervia/cli/cmd_application.py libervia/cli/cmd_avatar.py libervia/cli/cmd_blocking.py libervia/cli/cmd_blog.py libervia/cli/cmd_bookmarks.py libervia/cli/cmd_debug.py libervia/cli/cmd_encryption.py libervia/cli/cmd_event.py libervia/cli/cmd_file.py libervia/cli/cmd_forums.py libervia/cli/cmd_identity.py libervia/cli/cmd_info.py libervia/cli/cmd_input.py libervia/cli/cmd_invitation.py libervia/cli/cmd_list.py libervia/cli/cmd_merge_request.py libervia/cli/cmd_message.py libervia/cli/cmd_param.py libervia/cli/cmd_ping.py libervia/cli/cmd_pipe.py libervia/cli/cmd_profile.py libervia/cli/cmd_pubsub.py libervia/cli/cmd_roster.py libervia/cli/cmd_shell.py libervia/cli/cmd_uri.py libervia/cli/common.py libervia/cli/constants.py libervia/cli/loops.py libervia/cli/output_std.py libervia/cli/output_template.py libervia/cli/output_xml.py libervia/cli/output_xmlui.py libervia/cli/xml_tools.py libervia/cli/xmlui_manager.py libervia/frontends/jp/__init__.py libervia/frontends/jp/arg_tools.py libervia/frontends/jp/base.py libervia/frontends/jp/cmd_account.py libervia/frontends/jp/cmd_adhoc.py libervia/frontends/jp/cmd_application.py libervia/frontends/jp/cmd_avatar.py libervia/frontends/jp/cmd_blocking.py libervia/frontends/jp/cmd_blog.py libervia/frontends/jp/cmd_bookmarks.py libervia/frontends/jp/cmd_debug.py libervia/frontends/jp/cmd_encryption.py libervia/frontends/jp/cmd_event.py libervia/frontends/jp/cmd_file.py libervia/frontends/jp/cmd_forums.py libervia/frontends/jp/cmd_identity.py libervia/frontends/jp/cmd_info.py libervia/frontends/jp/cmd_input.py libervia/frontends/jp/cmd_invitation.py libervia/frontends/jp/cmd_list.py libervia/frontends/jp/cmd_merge_request.py libervia/frontends/jp/cmd_message.py libervia/frontends/jp/cmd_param.py libervia/frontends/jp/cmd_ping.py libervia/frontends/jp/cmd_pipe.py libervia/frontends/jp/cmd_profile.py libervia/frontends/jp/cmd_pubsub.py libervia/frontends/jp/cmd_roster.py libervia/frontends/jp/cmd_shell.py libervia/frontends/jp/cmd_uri.py libervia/frontends/jp/common.py libervia/frontends/jp/constants.py libervia/frontends/jp/loops.py libervia/frontends/jp/output_std.py libervia/frontends/jp/output_template.py libervia/frontends/jp/output_xml.py libervia/frontends/jp/output_xmlui.py libervia/frontends/jp/xml_tools.py libervia/frontends/jp/xmlui_manager.py setup.py
diffstat 80 files changed, 15070 insertions(+), 15069 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/backend/core/launcher.py	Fri Jun 02 14:12:38 2023 +0200
+++ b/libervia/backend/core/launcher.py	Fri Jun 02 14:54:26 2023 +0200
@@ -73,7 +73,7 @@
             pid = int(pid_file.read_text())
         except Exception as e:
             print(f"Can't read PID file at {pid_file}: {e}")
-            # we use the same exit code as DATA_ERROR in jp
+            # we use the same exit code as DATA_ERROR in CLI frontend
             sys.exit(17)
         print(f"Terminating {self.APP_NAME}…")
         os.kill(pid, signal.SIGTERM)
@@ -114,7 +114,7 @@
                 pid = int(pid_file.read_text())
             except Exception as e:
                 print(f"Can't read PID file at {pid_file}: {e}")
-                # we use the same exit code as DATA_ERROR in jp
+                # we use the same exit code as DATA_ERROR in CLI frontend
                 sys.exit(17)
             # we check if there is a process
             # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314
--- a/libervia/backend/plugins/plugin_blog_import_dokuwiki.py	Fri Jun 02 14:12:38 2023 +0200
+++ b/libervia/backend/plugins/plugin_blog_import_dokuwiki.py	Fri Jun 02 14:54:26 2023 +0200
@@ -86,9 +86,9 @@
 media_repo: URL to the new remote media repository (default: none)
 limit: maximal number of posts to import (default: 100)
 
-Example of usage (with jp frontend):
+Example of usage (with CLI frontend):
 
-jp import dokuwiki -p dave --pwd xxxxxx --connect
+li import dokuwiki -p dave --pwd xxxxxx --connect
     http://127.0.1.1 -o user souliane -o passwd qwertz
     -o namespace public:2015:10
     -o media_repo http://media.diekulturvermittlung.at
--- a/libervia/backend/tools/common/template.py	Fri Jun 02 14:12:38 2023 +0200
+++ b/libervia/backend/tools/common/template.py	Fri Jun 02 14:54:26 2023 +0200
@@ -589,7 +589,7 @@
         @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
         @raise NotFound: requested site has not been found
         """
-        # FIXME: check use in jp, and include site
+        # FIXME: check use in CLI frontend, and include site
         site, theme, __ = self.env.loader.parse_template(template)
         if site is None:
             # absolute template
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/arg_tools.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+
+
+def escape(arg, smart=True):
+    """format arg with quotes
+
+    @param smart(bool): if True, only escape if needed
+    """
+    if smart and not " " in arg and not '"' in arg:
+        return arg
+    return '"' + arg.replace('"', '\\"') + '"'
+
+
+def get_cmd_choices(cmd=None, parser=None):
+    try:
+        choices = parser._subparsers._group_actions[0].choices
+        return choices[cmd] if cmd is not None else choices
+    except (KeyError, AttributeError):
+        raise exceptions.NotFound
+
+
+def get_use_args(host, args, use, verbose=False, parser=None):
+    """format args for argparse parser with values prefilled
+
+    @param host(LiberviaCli): LiberviaCli instance
+    @param args(list(str)): arguments to use
+    @param use(dict[str, str]): arguments to fill if found in parser
+    @param verbose(bool): if True a message will be displayed when argument is used or not
+    @param parser(argparse.ArgumentParser): parser to use
+    @return (tuple[list[str],list[str]]): 2 args lists:
+        - parser args, i.e. given args corresponding to parsers
+        - use args, i.e. generated args from use
+    """
+    # FIXME: positional args are not handled correclty
+    #        if there is more that one, the position is not corrected
+    if parser is None:
+        parser = host.parser
+
+    # we check not optional args to see if there
+    # is a corresonding parser
+    # else USE args would not work correctly (only for current parser)
+    parser_args = []
+    for arg in args:
+        if arg.startswith("-"):
+            break
+        try:
+            parser = get_cmd_choices(arg, parser)
+        except exceptions.NotFound:
+            break
+        parser_args.append(arg)
+
+    # post_args are remaning given args,
+    # without the ones corresponding to parsers
+    post_args = args[len(parser_args) :]
+
+    opt_args = []
+    pos_args = []
+    actions = {a.dest: a for a in parser._actions}
+    for arg, value in use.items():
+        try:
+            if arg == "item" and not "item" in actions:
+                # small hack when --item is appended to a --items list
+                arg = "items"
+            action = actions[arg]
+        except KeyError:
+            if verbose:
+                host.disp(
+                    _(
+                        "ignoring {name}={value}, not corresponding to any argument (in USE)"
+                    ).format(name=arg, value=escape(value))
+                )
+        else:
+            if verbose:
+                host.disp(
+                    _("arg {name}={value} (in USE)").format(
+                        name=arg, value=escape(value)
+                    )
+                )
+            if not action.option_strings:
+                pos_args.append(value)
+            else:
+                opt_args.append(action.option_strings[0])
+                opt_args.append(value)
+    return parser_args, opt_args + pos_args + post_args
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/base.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,1435 @@
+#!/usr/bin/env python3
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+from libervia.backend.core.i18n import _
+
+### logging ###
+import logging as log
+log.basicConfig(level=log.WARNING,
+                format='[%(name)s] %(message)s')
+###
+
+import sys
+import os
+import os.path
+import argparse
+import inspect
+import tty
+import termios
+from pathlib import Path
+from glob import iglob
+from typing import Optional, Set, Union
+from importlib import import_module
+from libervia.frontends.tools.jid import JID
+from libervia.backend.tools import config
+from libervia.backend.tools.common import dynamic_import
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core import exceptions
+import libervia.cli
+from libervia.cli.loops import QuitException, get_libervia_cli_loop
+from libervia.cli.constants import Const as C
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.frontends.tools import misc
+import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
+from collections import OrderedDict
+
+## bridge handling
+# we get bridge name from conf and initialise the right class accordingly
+main_config = config.parse_main_conf()
+bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
+LiberviaCLILoop = get_libervia_cli_loop(bridge_name)
+
+
+try:
+    import progressbar
+except ImportError:
+    msg = (_('ProgressBar not available, please download it at '
+             'http://pypi.python.org/pypi/progressbar\n'
+             'Progress bar deactivated\n--\n'))
+    print(msg, file=sys.stderr)
+    progressbar=None
+
+#consts
+DESCRIPTION = """This software is a command line tool for XMPP.
+Get the latest version at """ + C.APP_URL
+
+COPYLEFT = """Copyright (C) 2009-2021 Jérôme Poisson, Adrien Cossa
+This program comes with ABSOLUTELY NO WARRANTY;
+This is free software, and you are welcome to redistribute it under certain conditions.
+"""
+
+PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s
+
+
+def date_decoder(arg):
+    return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL)
+
+
+class LiberviaCli:
+    """
+    This class can be use to establish a connection with the
+    bridge. Moreover, it should manage a main loop.
+
+    To use it, you mainly have to redefine the method run to perform
+    specify what kind of operation you want to perform.
+
+    """
+    def __init__(self):
+        """
+
+        @attribute quit_on_progress_end (bool): set to False if you manage yourself
+            exiting, or if you want the user to stop by himself
+        @attribute progress_success(callable): method to call when progress just started
+            by default display a message
+        @attribute progress_success(callable): method to call when progress is
+            successfully finished by default display a message
+        @attribute progress_failure(callable): method to call when progress failed
+            by default display a message
+        """
+        self.sat_conf = main_config
+        self.set_color_theme()
+        bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge')
+        if bridge_module is None:
+            log.error("Can't import {} bridge".format(bridge_name))
+            sys.exit(1)
+
+        self.bridge = bridge_module.AIOBridge()
+        self._onQuitCallbacks = []
+
+    def get_config(self, name, section=C.CONFIG_SECTION, default=None):
+        """Retrieve a setting value from sat.conf"""
+        return config.config_get(self.sat_conf, section, name, default=default)
+
+    def guess_background(self):
+        # cf. https://unix.stackexchange.com/a/245568 (thanks!)
+        try:
+            # for VTE based terminals
+            vte_version = int(os.getenv("VTE_VERSION", 0))
+        except ValueError:
+            vte_version = 0
+
+        color_fg_bg = os.getenv("COLORFGBG")
+
+        if ((sys.stdin.isatty() and sys.stdout.isatty()
+             and (
+                 # XTerm
+                 os.getenv("XTERM_VERSION")
+                 # Konsole
+                 or os.getenv("KONSOLE_VERSION")
+                 # All VTE based terminals
+                 or vte_version >= 3502
+             ))):
+            # ANSI escape sequence
+            stdin_fd = sys.stdin.fileno()
+            old_settings = termios.tcgetattr(stdin_fd)
+            try:
+                tty.setraw(sys.stdin.fileno())
+                # we request background color
+                sys.stdout.write("\033]11;?\a")
+                sys.stdout.flush()
+                expected = "\033]11;rgb:"
+                for c in expected:
+                    ch = sys.stdin.read(1)
+                    if ch != c:
+                        # background id is not supported, we default to "dark"
+                        # TODO: log something?
+                        return 'dark'
+                red, green, blue = [
+                    int(c, 16)/65535 for c in sys.stdin.read(14).split('/')
+                ]
+                # '\a' is the last character
+                sys.stdin.read(1)
+            finally:
+                termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
+
+            lum = utils.per_luminance(red, green, blue)
+            if lum <= 0.5:
+                return 'dark'
+            else:
+                return 'light'
+        elif color_fg_bg:
+            # no luck with ANSI escape sequence, we try COLORFGBG environment variable
+            try:
+                bg = int(color_fg_bg.split(";")[-1])
+            except ValueError:
+                return "dark"
+            if bg in list(range(7)) + [8]:
+                return "dark"
+            else:
+                return "light"
+        else:
+            # no autodetection method found
+            return "dark"
+
+    def set_color_theme(self):
+        background = self.get_config('background', default='auto')
+        if background == 'auto':
+            background = self.guess_background()
+        if background not in ('dark', 'light'):
+            raise exceptions.ConfigError(_(
+                'Invalid value set for "background" ({background}), please check '
+                'your settings in libervia.conf').format(
+                    background=repr(background)
+                ))
+        self.background = background
+        if background == 'light':
+            C.A_HEADER = A.FG_MAGENTA
+            C.A_SUBHEADER = A.BOLD + A.FG_RED
+            C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
+            C.A_SUCCESS = A.FG_GREEN
+            C.A_FAILURE = A.BOLD + A.FG_RED
+            C.A_WARNING = A.FG_RED
+            C.A_PROMPT_PATH = A.FG_BLUE
+            C.A_PROMPT_SUF = A.BOLD
+            C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA
+            C.A_FILE = A.FG_BLACK
+
+    def _bridge_connected(self):
+        self.parser = argparse.ArgumentParser(
+            formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION)
+        self._make_parents()
+        self.add_parser_options()
+        self.subparsers = self.parser.add_subparsers(
+            title=_('Available commands'), dest='command', required=True)
+
+        # progress attributes
+        self._progress_id = None # TODO: manage several progress ids
+        self.quit_on_progress_end = True
+
+        # outputs
+        self._outputs = {}
+        for type_ in C.OUTPUT_TYPES:
+            self._outputs[type_] = OrderedDict()
+        self.default_output = {}
+
+        self.own_jid = None  # must be filled at runtime if needed
+
+    @property
+    def progress_id(self):
+        return self._progress_id
+
+    async def set_progress_id(self, progress_id):
+        # because we use async, we need an explicit setter
+        self._progress_id = progress_id
+        await self.replay_cache('progress_ids_cache')
+
+    @property
+    def watch_progress(self):
+        try:
+            self.pbar
+        except AttributeError:
+            return False
+        else:
+            return True
+
+    @watch_progress.setter
+    def watch_progress(self, watch_progress):
+        if watch_progress:
+            self.pbar = None
+
+    @property
+    def verbosity(self):
+        try:
+            return self.args.verbose
+        except AttributeError:
+            return 0
+
+    async def replay_cache(self, cache_attribute):
+        """Replay cached signals
+
+        @param cache_attribute(str): name of the attribute containing the cache
+            if the attribute doesn't exist, there is no cache and the call is ignored
+            else the cache must be a list of tuples containing the replay callback as
+            first item, then the arguments to use
+        """
+        try:
+            cache = getattr(self, cache_attribute)
+        except AttributeError:
+            pass
+        else:
+            for cache_data in cache:
+                await cache_data[0](*cache_data[1:])
+
+    def disp(self, msg, verbosity=0, error=False, end='\n'):
+        """Print a message to user
+
+        @param msg(unicode): message to print
+        @param verbosity(int): minimal verbosity to display the message
+        @param error(bool): if True, print to stderr instead of stdout
+        @param end(str): string appended after the last value, default a newline
+        """
+        if self.verbosity >= verbosity:
+            if error:
+                print(msg, end=end, file=sys.stderr)
+            else:
+                print(msg, end=end)
+
+    async def output(self, type_, name, extra_outputs, data):
+        if name in extra_outputs:
+            method = extra_outputs[name]
+        else:
+            method = self._outputs[type_][name]['callback']
+
+        ret = method(data)
+        if inspect.isawaitable(ret):
+            await ret
+
+    def add_on_quit_callback(self, callback, *args, **kwargs):
+        """Add a callback which will be called on quit command
+
+        @param callback(callback): method to call
+        """
+        self._onQuitCallbacks.append((callback, args, kwargs))
+
+    def get_output_choices(self, output_type):
+        """Return valid output filters for output_type
+
+        @param output_type: True for default,
+            else can be any registered type
+        """
+        return list(self._outputs[output_type].keys())
+
+    def _make_parents(self):
+        self.parents = {}
+
+        # we have a special case here as the start-session option is present only if
+        # connection is not needed, so we create two similar parents, one with the
+        # option, the other one without it
+        for parent_name in ('profile', 'profile_session'):
+            parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False)
+            parent.add_argument(
+                "-p", "--profile", action="store", type=str, default='@DEFAULT@',
+                help=_("Use PROFILE profile key (default: %(default)s)"))
+            parent.add_argument(
+                "--pwd", action="store", metavar='PASSWORD',
+                help=_("Password used to connect profile, if necessary"))
+
+        profile_parent, profile_session_parent = (self.parents['profile'],
+                                                  self.parents['profile_session'])
+
+        connect_short, connect_long, connect_action, connect_help = (
+            "-c", "--connect", "store_true",
+            _("Connect the profile before doing anything else")
+        )
+        profile_parent.add_argument(
+            connect_short, connect_long, action=connect_action, help=connect_help)
+
+        profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group()
+        profile_session_connect_group.add_argument(
+            connect_short, connect_long, action=connect_action, help=connect_help)
+        profile_session_connect_group.add_argument(
+            "--start-session", action="store_true",
+            help=_("Start a profile session without connecting"))
+
+        progress_parent = self.parents['progress'] = argparse.ArgumentParser(
+            add_help=False)
+        if progressbar:
+            progress_parent.add_argument(
+                "-P", "--progress", action="store_true", help=_("Show progress bar"))
+
+        verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False)
+        verbose_parent.add_argument(
+            '--verbose', '-v', action='count', default=0,
+            help=_("Add a verbosity level (can be used multiple times)"))
+
+        quiet_parent = self.parents['quiet'] = argparse.ArgumentParser(add_help=False)
+        quiet_parent.add_argument(
+            '--quiet', '-q', action='store_true',
+            help=_("be quiet (only output machine readable data)"))
+
+        draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False)
+        draft_group = draft_parent.add_argument_group(_('draft handling'))
+        draft_group.add_argument(
+            "-D", "--current", action="store_true", help=_("load current draft"))
+        draft_group.add_argument(
+            "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve"))
+
+
+    def make_pubsub_group(self, flags, defaults):
+        """Generate pubsub options according to flags
+
+        @param flags(iterable[unicode]): see [CommandBase.__init__]
+        @param defaults(dict[unicode, unicode]): help text for default value
+            key can be "service" or "node"
+            value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT
+        @return (ArgumentParser): parser to add
+        """
+        flags = misc.FlagsHandler(flags)
+        parent = argparse.ArgumentParser(add_help=False)
+        pubsub_group = parent.add_argument_group('pubsub')
+        pubsub_group.add_argument("-u", "--pubsub-url",
+                                  help=_("Pubsub URL (xmpp or http)"))
+
+        service_help = _("JID of the PubSub service")
+        if not flags.service:
+            default = defaults.pop('service', _('PEP service'))
+            if default is not None:
+                service_help += _(" (DEFAULT: {default})".format(default=default))
+        pubsub_group.add_argument("-s", "--service", default='',
+                                  help=service_help)
+
+        node_help = _("node to request")
+        if not flags.node:
+            default = defaults.pop('node', _('standard node'))
+            if default is not None:
+                node_help += _(" (DEFAULT: {default})".format(default=default))
+        pubsub_group.add_argument("-n", "--node", default='', help=node_help)
+
+        if flags.single_item:
+            item_help = ("item to retrieve")
+            if not flags.item:
+                default = defaults.pop('item', _('last item'))
+                if default is not None:
+                    item_help += _(" (DEFAULT: {default})".format(default=default))
+            pubsub_group.add_argument("-i", "--item", default='',
+                                      help=item_help)
+            pubsub_group.add_argument(
+                "-L", "--last-item", action='store_true', help=_('retrieve last item'))
+        elif flags.multi_items:
+            # mutiple items, this activate several features: max-items, RSM, MAM
+            # and Orbder-by
+            pubsub_group.add_argument(
+                "-i", "--item", action='append', dest='items', default=[],
+                help=_("items to retrieve (DEFAULT: all)"))
+            if not flags.no_max:
+                max_group = pubsub_group.add_mutually_exclusive_group()
+                # XXX: defaut value for --max-items or --max is set in parse_pubsub_args
+                max_group.add_argument(
+                    "-M", "--max-items", dest="max", type=int,
+                    help=_("maximum number of items to get ({no_limit} to get all items)"
+                           .format(no_limit=C.NO_LIMIT)))
+                # FIXME: it could be possible to no duplicate max (between pubsub
+                #        max-items and RSM max)should not be duplicated, RSM could be
+                #        used when available and pubsub max otherwise
+                max_group.add_argument(
+                    "-m", "--max", dest="rsm_max", type=int,
+                    help=_("maximum number of items to get per page (DEFAULT: 10)"))
+
+            # RSM
+
+            rsm_page_group = pubsub_group.add_mutually_exclusive_group()
+            rsm_page_group.add_argument(
+                "-a", "--after", dest="rsm_after",
+                help=_("find page after this item"), metavar='ITEM_ID')
+            rsm_page_group.add_argument(
+                "-b", "--before", dest="rsm_before",
+                help=_("find page before this item"), metavar='ITEM_ID')
+            rsm_page_group.add_argument(
+                "--index", dest="rsm_index", type=int,
+                help=_("index of the first item to retrieve"))
+
+
+            # MAM
+
+            pubsub_group.add_argument(
+                "-f", "--filter", dest='mam_filters', nargs=2,
+                action='append', default=[], help=_("MAM filters to use"),
+                metavar=("FILTER_NAME", "VALUE")
+            )
+
+            # Order-By
+
+            # TODO: order-by should be a list to handle several levels of ordering
+            #       but this is not yet done in SàT (and not really useful with
+            #       current specifications, as only "creation" and "modification" are
+            #       available)
+            pubsub_group.add_argument(
+                "-o", "--order-by", choices=[C.ORDER_BY_CREATION,
+                                             C.ORDER_BY_MODIFICATION],
+                help=_("how items should be ordered"))
+
+        if flags[C.CACHE]:
+            pubsub_group.add_argument(
+                "-C", "--no-cache", dest="use_cache", action='store_false',
+                help=_("don't use Pubsub cache")
+            )
+
+        if not flags.all_used:
+            raise exceptions.InternalError('unknown flags: {flags}'.format(
+                flags=', '.join(flags.unused)))
+        if defaults:
+            raise exceptions.InternalError(f'unused defaults: {defaults}')
+
+        return parent
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            '--version',
+            action='version',
+            version=("{name} {version} {copyleft}".format(
+                name = C.APP_NAME,
+                version = self.version,
+                copyleft = COPYLEFT))
+        )
+
+    def register_output(self, type_, name, callback, description="", default=False):
+        if type_ not in C.OUTPUT_TYPES:
+            log.error("Invalid output type {}".format(type_))
+            return
+        self._outputs[type_][name] = {'callback': callback,
+                                      'description': description
+                                     }
+        if default:
+            if type_ in self.default_output:
+                self.disp(
+                    _('there is already a default output for {type}, ignoring new one')
+                    .format(type=type_)
+                )
+            else:
+                self.default_output[type_] = name
+
+
+    def parse_output_options(self):
+        options = self.command.args.output_opts
+        options_dict = {}
+        for option in options:
+            try:
+                key, value = option.split('=', 1)
+            except ValueError:
+                key, value = option, None
+            options_dict[key.strip()] = value.strip() if value is not None else None
+        return options_dict
+
+    def check_output_options(self, accepted_set, options):
+        if not accepted_set.issuperset(options):
+            self.disp(
+                _("The following output options are invalid: {invalid_options}").format(
+                invalid_options = ', '.join(set(options).difference(accepted_set))),
+                error=True)
+            self.quit(C.EXIT_BAD_ARG)
+
+    def import_plugins(self):
+        """Automaticaly import commands and outputs in CLI frontend
+
+        looks from modules names cmd_*.py in CLI frontend path and import them
+        """
+        path = os.path.dirname(libervia.cli.__file__)
+        # XXX: outputs must be imported before commands as they are used for arguments
+        for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'),
+                               (C.PLUGIN_CMD, 'cmd_*.py')):
+            modules = (
+                os.path.splitext(module)[0]
+                for module in map(os.path.basename, iglob(os.path.join(path, pattern))))
+            for module_name in modules:
+                module_path = "libervia.cli." + module_name
+                try:
+                    module = import_module(module_path)
+                    self.import_plugin_module(module, type_)
+                except ImportError as e:
+                    self.disp(
+                        _("Can't import {module_path} plugin, ignoring it: {e}")
+                        .format(module_path=module_path, e=e),
+                        error=True)
+                except exceptions.CancelError:
+                    continue
+                except exceptions.MissingModule as e:
+                    self.disp(_("Missing module for plugin {name}: {missing}".format(
+                        name = module_path,
+                        missing = e)), error=True)
+
+
+    def import_plugin_module(self, module, type_):
+        """add commands or outpus from a module to CLI frontend
+
+        @param module: module containing commands or outputs
+        @param type_(str): one of C_PLUGIN_*
+        """
+        try:
+            class_names =  getattr(module, '__{}__'.format(type_))
+        except AttributeError:
+            log.disp(
+                _("Invalid plugin module [{type}] {module}")
+                .format(type=type_, module=module),
+                error=True)
+            raise ImportError
+        else:
+            for class_name in class_names:
+                cls = getattr(module, class_name)
+                cls(self)
+
+    def get_xmpp_uri_from_http(self, http_url):
+        """parse HTML page at http(s) URL, and looks for xmpp: uri"""
+        if http_url.startswith('https'):
+            scheme = 'https'
+        elif http_url.startswith('http'):
+            scheme = 'http'
+        else:
+            raise exceptions.InternalError('An HTTP scheme is expected in this method')
+        self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1)
+        # HTTP URL, we try to find xmpp: links
+        try:
+            from lxml import etree
+        except ImportError:
+            self.disp(
+                "lxml module must be installed to use http(s) scheme, please install it "
+                "with \"pip install lxml\"",
+                error=True)
+            self.quit(1)
+        import urllib.request, urllib.error, urllib.parse
+        parser = etree.HTMLParser()
+        try:
+            root = etree.parse(urllib.request.urlopen(http_url), parser)
+        except etree.XMLSyntaxError as e:
+            self.disp(_("Can't parse HTML page : {msg}").format(msg=e))
+            links = []
+        else:
+            links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
+        if not links:
+            self.disp(
+                _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP '
+                  'PubSub node/item'),
+                error=True)
+            self.quit(1)
+        xmpp_uri = links[0].get('href')
+        return xmpp_uri
+
+    def parse_pubsub_args(self):
+        if self.args.pubsub_url is not None:
+            url = self.args.pubsub_url
+
+            if url.startswith('http'):
+                # http(s) URL, we try to retrieve xmpp one from there
+                url = self.get_xmpp_uri_from_http(url)
+
+            try:
+                uri_data = uri.parse_xmpp_uri(url)
+            except ValueError:
+                self.parser.error(_('invalid XMPP URL: {url}').format(url=url))
+            else:
+                if uri_data['type'] == 'pubsub':
+                    # URL is alright, we only set data not already set by other options
+                    if not self.args.service:
+                        self.args.service = uri_data['path']
+                    if not self.args.node:
+                        self.args.node = uri_data['node']
+                    uri_item = uri_data.get('item')
+                    if uri_item:
+                        # there is an item in URI
+                        # we use it only if item is not already set
+                        # and item_last is not used either
+                        try:
+                            item = self.args.item
+                        except AttributeError:
+                            try:
+                                items = self.args.items
+                            except AttributeError:
+                                self.disp(
+                                    _("item specified in URL but not needed in command, "
+                                      "ignoring it"),
+                                    error=True)
+                            else:
+                                if not items:
+                                    self.args.items = [uri_item]
+                        else:
+                            if not item:
+                                try:
+                                    item_last = self.args.item_last
+                                except AttributeError:
+                                    item_last = False
+                                if not item_last:
+                                    self.args.item = uri_item
+                else:
+                    self.parser.error(
+                        _('XMPP URL is not a pubsub one: {url}').format(url=url)
+                    )
+        flags = self.args._cmd._pubsub_flags
+        # we check required arguments here instead of using add_arguments' required option
+        # because the required argument can be set in URL
+        if C.SERVICE in flags and not self.args.service:
+            self.parser.error(_("argument -s/--service is required"))
+        if C.NODE in flags and not self.args.node:
+            self.parser.error(_("argument -n/--node is required"))
+        if C.ITEM in flags and not self.args.item:
+            self.parser.error(_("argument -i/--item is required"))
+
+        # FIXME: mutually groups can't be nested in a group and don't support title
+        #        so we check conflict here. This may be fixed in Python 3, to be checked
+        try:
+            if self.args.item and self.args.item_last:
+                self.parser.error(
+                    _("--item and --item-last can't be used at the same time"))
+        except AttributeError:
+            pass
+
+        try:
+            max_items = self.args.max
+            rsm_max = self.args.rsm_max
+        except AttributeError:
+            pass
+        else:
+            # we need to set a default value for max, but we need to know if we want
+            # to use pubsub's max or RSM's max. The later is used if any RSM or MAM
+            # argument is set
+            if max_items is None and rsm_max is None:
+                to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before',
+                            'rsm_index')
+                if any((getattr(self.args, name) for name in to_check)):
+                    # we use RSM
+                    self.args.rsm_max = 10
+                else:
+                    # we use pubsub without RSM
+                    self.args.max = 10
+            if self.args.max is None:
+                self.args.max = C.NO_LIMIT
+
+    async def main(self, args, namespace):
+        try:
+            await self.bridge.bridge_connect()
+        except Exception as e:
+            if isinstance(e, exceptions.BridgeExceptionNoService):
+                print(
+                    _("Can't connect to Libervia backend, are you sure that it's "
+                      "launched ?")
+                )
+                self.quit(C.EXIT_BACKEND_NOT_FOUND, raise_exc=False)
+            elif isinstance(e, exceptions.BridgeInitError):
+                print(_("Can't init bridge"))
+                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
+            else:
+                print(
+                    _("Error while initialising bridge: {e}").format(e=e)
+                )
+                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
+            return
+        await self.bridge.ready_get()
+        self.version = await self.bridge.version_get()
+        self._bridge_connected()
+        self.import_plugins()
+        try:
+            self.args = self.parser.parse_args(args, namespace=None)
+            if self.args._cmd._use_pubsub:
+                self.parse_pubsub_args()
+            await self.args._cmd.run()
+        except SystemExit as e:
+            self.quit(e.code, raise_exc=False)
+            return
+        except QuitException:
+            return
+
+    def _run(self, args=None, namespace=None):
+        self.loop = LiberviaCLILoop()
+        self.loop.run(self, args, namespace)
+
+    @classmethod
+    def run(cls):
+        cls()._run()
+
+    def _read_stdin(self, stdin_fut):
+        """Callback called by ainput to read stdin"""
+        line = sys.stdin.readline()
+        if line:
+            stdin_fut.set_result(line.rstrip(os.linesep))
+        else:
+            stdin_fut.set_exception(EOFError())
+
+    async def ainput(self, msg=''):
+        """Asynchronous version of buildin "input" function"""
+        self.disp(msg, end=' ')
+        sys.stdout.flush()
+        loop = asyncio.get_running_loop()
+        stdin_fut = loop.create_future()
+        loop.add_reader(sys.stdin, self._read_stdin, stdin_fut)
+        return await stdin_fut
+
+    async def confirm(self, message):
+        """Request user to confirm action, return answer as boolean"""
+        res = await self.ainput(f"{message} (y/N)? ")
+        return res in ("y", "Y")
+
+    async def confirm_or_quit(self, message, cancel_message=_("action cancelled by user")):
+        """Request user to confirm action, and quit if he doesn't"""
+        confirmed = await self.confirm(message)
+        if not confirmed:
+            self.disp(cancel_message)
+            self.quit(C.EXIT_USER_CANCELLED)
+
+    def quit_from_signal(self, exit_code=0):
+        r"""Same as self.quit, but from a signal handler
+
+        /!\: return must be used after calling this method !
+        """
+        # XXX: python-dbus will show a traceback if we exit in a signal handler
+        # so we use this little timeout trick to avoid it
+        self.loop.call_later(0, self.quit, exit_code)
+
+    def quit(self, exit_code=0, raise_exc=True):
+        """Terminate the execution with specified exit_code
+
+        This will stop the loop.
+        @param exit_code(int): code to return when quitting the program
+        @param raise_exp(boolean): if True raise a QuitException to stop code execution
+            The default value should be used most of time.
+        """
+        # first the onQuitCallbacks
+        try:
+            callbacks_list = self._onQuitCallbacks
+        except AttributeError:
+            pass
+        else:
+            for callback, args, kwargs in callbacks_list:
+                callback(*args, **kwargs)
+
+        self.loop.quit(exit_code)
+        if raise_exc:
+            raise QuitException
+
+    async def check_jids(self, jids):
+        """Check jids validity, transform roster name to corresponding jids
+
+        @param profile: profile name
+        @param jids: list of jids
+        @return: List of jids
+
+        """
+        names2jid = {}
+        nodes2jid = {}
+
+        try:
+            contacts = await self.bridge.contacts_get(self.profile)
+        except BridgeException as e:
+            if e.classname == "AttributeError":
+                # we may get an AttributeError if we use a component profile
+                # as components don't have roster
+                contacts = []
+            else:
+                raise e
+
+        for contact in contacts:
+            jid_s, attr, groups = contact
+            _jid = JID(jid_s)
+            try:
+                names2jid[attr["name"].lower()] = jid_s
+            except KeyError:
+                pass
+
+            if _jid.node:
+                nodes2jid[_jid.node.lower()] = jid_s
+
+        def expand_jid(jid):
+            _jid = jid.lower()
+            if _jid in names2jid:
+                expanded = names2jid[_jid]
+            elif _jid in nodes2jid:
+                expanded = nodes2jid[_jid]
+            else:
+                expanded = jid
+            return expanded
+
+        def check(jid):
+            if not jid.is_valid:
+                log.error (_("%s is not a valid JID !"), jid)
+                self.quit(1)
+
+        dest_jids=[]
+        try:
+            for i in range(len(jids)):
+                dest_jids.append(expand_jid(jids[i]))
+                check(dest_jids[i])
+        except AttributeError:
+            pass
+
+        return dest_jids
+
+    async def a_pwd_input(self, msg=''):
+        """Like ainput but with echo disabled (useful for passwords)"""
+        # we disable echo, code adapted from getpass standard module which has been
+        # written by Piers Lauder (original), Guido van Rossum (Windows support and
+        # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks
+        # to them (and for all the amazing work on Python).
+        stdin_fd = sys.stdin.fileno()
+        old = termios.tcgetattr(sys.stdin)
+        new = old[:]
+        new[3] &= ~termios.ECHO
+        tcsetattr_flags = termios.TCSAFLUSH
+        if hasattr(termios, 'TCSASOFT'):
+            tcsetattr_flags |= termios.TCSASOFT
+        try:
+            termios.tcsetattr(stdin_fd, tcsetattr_flags, new)
+            pwd = await self.ainput(msg=msg)
+        finally:
+            termios.tcsetattr(stdin_fd, tcsetattr_flags, old)
+            sys.stderr.flush()
+        self.disp('')
+        return pwd
+
+    async def connect_or_prompt(self, method, err_msg=None):
+        """Try to connect/start profile session and prompt for password if needed
+
+        @param method(callable): bridge method to either connect or start profile session
+            It will be called with password as sole argument, use lambda to do the call
+            properly
+        @param err_msg(str): message to show if connection fail
+        """
+        password = self.args.pwd
+        while True:
+            try:
+                await method(password or '')
+            except Exception as e:
+                if ((isinstance(e, BridgeException)
+                     and e.classname == 'PasswordError'
+                     and self.args.pwd is None)):
+                    if password is not None:
+                        self.disp(A.color(C.A_WARNING, _("invalid password")))
+                    password = await self.a_pwd_input(
+                        _("please enter profile password:"))
+                else:
+                    self.disp(err_msg.format(profile=self.profile, e=e), error=True)
+                    self.quit(C.EXIT_ERROR)
+            else:
+                break
+
+    async def connect_profile(self):
+        """Check if the profile is connected and do it if requested
+
+        @exit: - 1 when profile is not connected and --connect is not set
+               - 1 when the profile doesn't exists
+               - 1 when there is a connection error
+        """
+        # FIXME: need better exit codes
+
+        self.profile = await self.bridge.profile_name_get(self.args.profile)
+
+        if not self.profile:
+            log.error(
+                _("The profile [{profile}] doesn't exist")
+                .format(profile=self.args.profile)
+            )
+            self.quit(C.EXIT_ERROR)
+
+        try:
+            start_session = self.args.start_session
+        except AttributeError:
+            pass
+        else:
+            if start_session:
+                await self.connect_or_prompt(
+                    lambda pwd: self.bridge.profile_start_session(pwd, self.profile),
+                    err_msg="Can't start {profile}'s session: {e}"
+                )
+                return
+            elif not await self.bridge.profile_is_session_started(self.profile):
+                if not self.args.connect:
+                    self.disp(_(
+                        "Session for [{profile}] is not started, please start it "
+                        "before using libervia-cli, or use either --start-session or "
+                        "--connect option"
+                        .format(profile=self.profile)
+                    ), error=True)
+                    self.quit(1)
+            elif not getattr(self.args, "connect", False):
+                return
+
+
+        if not hasattr(self.args, 'connect'):
+            # a profile can be present without connect option (e.g. on profile
+            # creation/deletion)
+            return
+        elif self.args.connect is True:  # if connection is asked, we connect the profile
+            await self.connect_or_prompt(
+                lambda pwd: self.bridge.connect(self.profile, pwd, {}),
+                err_msg = 'Can\'t connect profile "{profile!s}": {e}'
+            )
+            return
+        else:
+            if not await self.bridge.is_connected(self.profile):
+                log.error(
+                    _("Profile [{profile}] is not connected, please connect it "
+                      "before using libervia-cli, or use --connect option")
+                    .format(profile=self.profile)
+                )
+                self.quit(1)
+
+    async def get_full_jid(self, param_jid):
+        """Return the full jid if possible (add main resource when find a bare jid)"""
+        # TODO: to be removed, bare jid should work with all commands, notably for file
+        #   as backend now handle jingles message initiation
+        _jid = JID(param_jid)
+        if not _jid.resource:
+            #if the resource is not given, we try to add the main resource
+            main_resource = await self.bridge.main_resource_get(param_jid, self.profile)
+            if main_resource:
+                return f"{_jid.bare}/{main_resource}"
+        return param_jid
+
+    async def get_profile_jid(self):
+        """Retrieve current profile bare JID if possible"""
+        full_jid = await self.bridge.param_get_a_async(
+            "JabberID", "Connection", profile_key=self.profile
+        )
+        return full_jid.rsplit("/", 1)[0]
+
+
+class CommandBase:
+
+    def __init__(
+        self,
+        host: LiberviaCli,
+        name: str,
+        use_profile: bool = True,
+        use_output: Union[bool, str] = False,
+        extra_outputs: Optional[dict] = None,
+        need_connect: Optional[bool] = None,
+        help: Optional[str] = None,
+        **kwargs
+    ):
+        """Initialise CommandBase
+
+        @param host: LiberviaCli instance
+        @param name: name of the new command
+        @param use_profile: if True, add profile selection/connection commands
+        @param use_output: if not False, add --output option
+        @param extra_outputs: list of command specific outputs:
+            key is output name ("default" to use as main output)
+            value is a callable which will format the output (data will be used as only
+            argument)
+            if a key already exists with normal outputs, the extra one will be used
+        @param need_connect: True if profile connection is needed
+            False else (profile session must still be started)
+            None to set auto value (i.e. True if use_profile is set)
+            Can't be set if use_profile is False
+        @param help: help message to display
+        @param **kwargs: args passed to ArgumentParser
+            use_* are handled directly, they can be:
+            - use_progress(bool): if True, add progress bar activation option
+                progress* signals will be handled
+            - use_verbose(bool): if True, add verbosity option
+            - use_pubsub(bool): if True, add pubsub options
+                mandatory arguments are controlled by pubsub_req
+            - use_draft(bool): if True, add draft handling options
+            ** other arguments **
+            - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options,
+              can be:
+                C.SERVICE: service is required
+                C.NODE: node is required
+                C.ITEM: item is required
+                C.SINGLE_ITEM: only one item is allowed
+        """
+        try: # If we have subcommands, host is a CommandBase and we need to use host.host
+            self.host = host.host
+        except AttributeError:
+            self.host = host
+
+        # --profile option
+        parents = kwargs.setdefault('parents', set())
+        if use_profile:
+            # self.host.parents['profile'] is an ArgumentParser with profile connection
+            # arguments
+            if need_connect is None:
+                need_connect = True
+            parents.add(
+                self.host.parents['profile' if need_connect else 'profile_session'])
+        else:
+            assert need_connect is None
+        self.need_connect = need_connect
+        # from this point, self.need_connect is None if connection is not needed at all
+        # False if session starting is needed, and True if full connection is needed
+
+        # --output option
+        if use_output:
+            if extra_outputs is None:
+                extra_outputs = {}
+            self.extra_outputs = extra_outputs
+            if use_output == True:
+                use_output = C.OUTPUT_TEXT
+            assert use_output in C.OUTPUT_TYPES
+            self._output_type = use_output
+            output_parent = argparse.ArgumentParser(add_help=False)
+            choices = set(self.host.get_output_choices(use_output))
+            choices.update(extra_outputs)
+            if not choices:
+                raise exceptions.InternalError(
+                    "No choice found for {} output type".format(use_output))
+            try:
+                default = self.host.default_output[use_output]
+            except KeyError:
+                if 'default' in choices:
+                    default = 'default'
+                elif 'simple' in choices:
+                    default = 'simple'
+                else:
+                    default = list(choices)[0]
+            output_parent.add_argument(
+                '--output', '-O', choices=sorted(choices), default=default,
+                help=_("select output format (default: {})".format(default)))
+            output_parent.add_argument(
+                '--output-option', '--oo', action="append", dest='output_opts',
+                default=[], help=_("output specific option"))
+            parents.add(output_parent)
+        else:
+            assert extra_outputs is None
+
+        self._use_pubsub = kwargs.pop('use_pubsub', False)
+        if self._use_pubsub:
+            flags = kwargs.pop('pubsub_flags', [])
+            defaults = kwargs.pop('pubsub_defaults', {})
+            parents.add(self.host.make_pubsub_group(flags, defaults))
+            self._pubsub_flags = flags
+
+        # other common options
+        use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')}
+        for param, do_use in use_opts.items():
+            opt=param[4:] # if param is use_verbose, opt is verbose
+            if opt not in self.host.parents:
+                raise exceptions.InternalError("Unknown parent option {}".format(opt))
+            del kwargs[param]
+            if do_use:
+                parents.add(self.host.parents[opt])
+
+        self.parser = host.subparsers.add_parser(name, help=help, **kwargs)
+        if hasattr(self, "subcommands"):
+            self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True)
+        else:
+            self.parser.set_defaults(_cmd=self)
+        self.add_parser_options()
+
+    @property
+    def sat_conf(self):
+        return self.host.sat_conf
+
+    @property
+    def args(self):
+        return self.host.args
+
+    @property
+    def profile(self):
+        return self.host.profile
+
+    @property
+    def verbosity(self):
+        return self.host.verbosity
+
+    @property
+    def progress_id(self):
+        return self.host.progress_id
+
+    async def set_progress_id(self, progress_id):
+        return await self.host.set_progress_id(progress_id)
+
+    async def progress_started_handler(self, uid, metadata, profile):
+        if profile != self.profile:
+            return
+        if self.progress_id is None:
+            # the progress started message can be received before the id
+            # so we keep progress_started signals in cache to replay they
+            # when the progress_id is received
+            cache_data = (self.progress_started_handler, uid, metadata, profile)
+            try:
+                cache = self.host.progress_ids_cache
+            except AttributeError:
+                cache = self.host.progress_ids_cache = []
+            cache.append(cache_data)
+        else:
+            if self.host.watch_progress and uid == self.progress_id:
+                await self.on_progress_started(metadata)
+                while True:
+                    await asyncio.sleep(PROGRESS_DELAY)
+                    cont = await self.progress_update()
+                    if not cont:
+                        break
+
+    async def progress_finished_handler(self, uid, metadata, profile):
+        if profile != self.profile:
+            return
+        if uid == self.progress_id:
+            try:
+                self.host.pbar.finish()
+            except AttributeError:
+                pass
+            await self.on_progress_finished(metadata)
+            if self.host.quit_on_progress_end:
+                self.host.quit_from_signal()
+
+    async def progress_error_handler(self, uid, message, profile):
+        if profile != self.profile:
+            return
+        if uid == self.progress_id:
+            if self.args.progress:
+                self.disp('') # progress is not finished, so we skip a line
+            if self.host.quit_on_progress_end:
+                await self.on_progress_error(message)
+                self.host.quit_from_signal(C.EXIT_ERROR)
+
+    async def progress_update(self):
+        """This method is continualy called to update the progress bar
+
+        @return (bool): False to stop being called
+        """
+        data = await self.host.bridge.progress_get(self.progress_id, self.profile)
+        if data:
+            try:
+                size = data['size']
+            except KeyError:
+                self.disp(_("file size is not known, we can't show a progress bar"), 1,
+                          error=True)
+                return False
+            if self.host.pbar is None:
+                #first answer, we must construct the bar
+
+                # if the instance has a pbar_template attribute, it is used has model,
+                # else default one is used
+                # template is a list of part, where part can be either a str to show directly
+                # or a list where first argument is a name of a progressbar widget, and others
+                # are used as widget arguments
+                try:
+                    template = self.pbar_template
+                except AttributeError:
+                    template = [
+                        _("Progress: "), ["Percentage"], " ", ["Bar"], " ",
+                        ["FileTransferSpeed"], " ", ["ETA"]
+                    ]
+
+                widgets = []
+                for part in template:
+                    if isinstance(part, str):
+                        widgets.append(part)
+                    else:
+                        widget = getattr(progressbar, part.pop(0))
+                        widgets.append(widget(*part))
+
+                self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets)
+                self.host.pbar.start()
+
+            self.host.pbar.update(int(data['position']))
+
+        elif self.host.pbar is not None:
+            return False
+
+        await self.on_progress_update(data)
+
+        return True
+
+    async def on_progress_started(self, metadata):
+        """Called when progress has just started
+
+        can be overidden by a command
+        @param metadata(dict): metadata as sent by bridge.progress_started
+        """
+        self.disp(_("Operation started"), 2)
+
+    async def on_progress_update(self, metadata):
+        """Method called on each progress updata
+
+        can be overidden by a command to handle progress metadata
+        @para metadata(dict): metadata as returned by bridge.progress_get
+        """
+        pass
+
+    async def on_progress_finished(self, metadata):
+        """Called when progress has just finished
+
+        can be overidden by a command
+        @param metadata(dict): metadata as sent by bridge.progress_finished
+        """
+        self.disp(_("Operation successfully finished"), 2)
+
+    async def on_progress_error(self, e):
+        """Called when a progress failed
+
+        @param error_msg(unicode): error message as sent by bridge.progress_error
+        """
+        self.disp(_("Error while doing operation: {e}").format(e=e), error=True)
+
+    def disp(self, msg, verbosity=0, error=False, end='\n'):
+        return self.host.disp(msg, verbosity, error, end)
+
+    def output(self, data):
+        try:
+            output_type = self._output_type
+        except AttributeError:
+            raise exceptions.InternalError(
+                _('trying to use output when use_output has not been set'))
+        return self.host.output(output_type, self.args.output, self.extra_outputs, data)
+
+    def get_pubsub_extra(self, extra: Optional[dict] = None) -> str:
+        """Helper method to compute extra data from pubsub arguments
+
+        @param extra: base extra dict, or None to generate a new one
+        @return: serialised dict which can be used directly in the bridge for pubsub
+        """
+        if extra is None:
+            extra = {}
+        else:
+            intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys()))
+            if intersection:
+                raise exceptions.ConflictError(
+                    "given extra dict has conflicting keys with pubsub keys "
+                    "{intersection}".format(intersection=intersection))
+
+        # RSM
+
+        for attribute in ('max', 'after', 'before', 'index'):
+            key = 'rsm_' + attribute
+            if key in extra:
+                raise exceptions.ConflictError(
+                    "This key already exists in extra: u{key}".format(key=key))
+            value = getattr(self.args, key, None)
+            if value is not None:
+                extra[key] = str(value)
+
+        # MAM
+
+        if hasattr(self.args, 'mam_filters'):
+            for key, value in self.args.mam_filters:
+                key = 'filter_' + key
+                if key in extra:
+                    raise exceptions.ConflictError(
+                        "This key already exists in extra: u{key}".format(key=key))
+                extra[key] = value
+
+        # Order-By
+
+        try:
+            order_by = self.args.order_by
+        except AttributeError:
+            pass
+        else:
+            if order_by is not None:
+                extra[C.KEY_ORDER_BY] = self.args.order_by
+
+        # Cache
+        try:
+            use_cache = self.args.use_cache
+        except AttributeError:
+            pass
+        else:
+            if not use_cache:
+                extra[C.KEY_USE_CACHE] = use_cache
+
+        return data_format.serialise(extra)
+
+    def add_parser_options(self):
+        try:
+            subcommands = self.subcommands
+        except AttributeError:
+            # We don't have subcommands, the class need to implements add_parser_options
+            raise NotImplementedError
+
+        # now we add subcommands to ourself
+        for cls in subcommands:
+            cls(self)
+
+    def override_pubsub_flags(self, new_flags: Set[str]) -> None:
+        """Replace pubsub_flags given in __init__
+
+        useful when a command is extending an other command (e.g. blog command which does
+        the same as pubsub command, but with a default node)
+        """
+        self._pubsub_flags = new_flags
+
+    async def run(self):
+        """this method is called when a command is actually run
+
+        It set stuff like progression callbacks and profile connection
+        You should not overide this method: you should call self.start instead
+        """
+        # we keep a reference to run command, it may be useful e.g. for outputs
+        self.host.command = self
+
+        try:
+            show_progress = self.args.progress
+        except AttributeError:
+            # the command doesn't use progress bar
+            pass
+        else:
+            if show_progress:
+                self.host.watch_progress = True
+            # we need to register the following signal even if we don't display the
+            # progress bar
+            self.host.bridge.register_signal(
+                "progress_started", self.progress_started_handler)
+            self.host.bridge.register_signal(
+                "progress_finished", self.progress_finished_handler)
+            self.host.bridge.register_signal(
+                "progress_error", self.progress_error_handler)
+
+        if self.need_connect is not None:
+            await self.host.connect_profile()
+        await self.start()
+
+    async def start(self):
+        """This is the starting point of the command, this method must be overriden
+
+        at this point, profile are connected if needed
+        """
+        raise NotImplementedError
+
+
+class CommandAnswering(CommandBase):
+    """Specialised commands which answer to specific actions
+
+    to manage action_types answer,
+    """
+    action_callbacks = {} # XXX: set managed action types in a dict here:
+                          # key is the action_type, value is the callable
+                          # which will manage the answer. profile filtering is
+                          # already managed when callback is called
+
+    def __init__(self, *args, **kwargs):
+        super(CommandAnswering, self).__init__(*args, **kwargs)
+
+    async def on_action_new(
+        self,
+        action_data_s: str,
+        action_id: str,
+        security_limit: int,
+        profile: str
+    ) -> None:
+        if profile != self.profile:
+            return
+        action_data = data_format.deserialise(action_data_s)
+        try:
+            action_type = action_data['type']
+        except KeyError:
+            try:
+                xml_ui = action_data["xmlui"]
+            except KeyError:
+                pass
+            else:
+                self.on_xmlui(xml_ui)
+        else:
+            try:
+                callback = self.action_callbacks[action_type]
+            except KeyError:
+                pass
+            else:
+                await callback(action_data, action_id, security_limit, profile)
+
+    def on_xmlui(self, xml_ui):
+        """Display a dialog received from the backend.
+
+        @param xml_ui (unicode): dialog XML representation
+        """
+        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
+        #        should be available in the future
+        # TODO: XMLUI module
+        ui = ET.fromstring(xml_ui.encode('utf-8'))
+        dialog = ui.find("dialog")
+        if dialog is not None:
+            self.disp(dialog.findtext("message"), error=dialog.get("level") == "error")
+
+    async def start_answering(self):
+        """Auto reply to confirmation requests"""
+        self.host.bridge.register_signal("action_new", self.on_action_new)
+        actions = await self.host.bridge.actions_get(self.profile)
+        for action_data_s, action_id, security_limit in actions:
+            await self.on_action_new(action_data_s, action_id, security_limit, self.profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_account.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)"""
+
+from libervia.cli.constants import Const as C
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.cli import base
+from libervia.frontends.tools import jid
+
+
+log = getLogger(__name__)
+
+__commands__ = ["Account"]
+
+
+class AccountCreate(base.CommandBase):
+    def __init__(self, host):
+        super(AccountCreate, self).__init__(
+            host,
+            "create",
+            use_profile=False,
+            use_verbose=True,
+            help=_("create a XMPP account"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid", help=_("jid to create")
+        )
+        self.parser.add_argument(
+            "password", help=_("password of the account")
+        )
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            help=_(
+                "create a profile to use this account (default: don't create profile)"
+            ),
+        )
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            default="",
+            help=_("email (usage depends of XMPP server)"),
+        )
+        self.parser.add_argument(
+            "-H",
+            "--host",
+            default="",
+            help=_("server host (IP address or domain, default: use localhost)"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--port",
+            type=int,
+            default=0,
+            help=_("server port (default: {port})").format(
+                port=C.XMPP_C2S_PORT
+            ),
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.in_band_account_new(
+                self.args.jid,
+                self.args.password,
+                self.args.email,
+                self.args.host,
+                self.args.port,
+            )
+
+        except BridgeException as e:
+            if e.condition == 'conflict':
+                self.disp(
+                    f"The account {self.args.jid} already exists",
+                    error=True
+                )
+                self.host.quit(C.EXIT_CONFLICT)
+            else:
+                self.disp(
+                    f"can't create account on {self.args.host or 'localhost'!r} with jid "
+                    f"{self.args.jid!r} using In-Band Registration: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+
+        self.disp(_("XMPP account created"), 1)
+
+        if self.args.profile is None:
+            self.host.quit()
+
+
+        self.disp(_("creating profile"), 2)
+        try:
+            await self.host.bridge.profile_create(
+                self.args.profile,
+                self.args.password,
+                "",
+            )
+        except BridgeException as e:
+            if e.condition == 'conflict':
+                self.disp(
+                    f"The profile {self.args.profile} already exists",
+                    error=True
+                )
+                self.host.quit(C.EXIT_CONFLICT)
+            else:
+                self.disp(
+                    _("Can't create profile {profile} to associate with jid "
+                      "{jid}: {e}").format(
+                          profile=self.args.profile,
+                          jid=self.args.jid,
+                          e=e
+                      ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+
+        self.disp(_("profile created"), 1)
+        try:
+            await self.host.bridge.profile_start_session(
+                self.args.password,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't start profile session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        try:
+            await self.host.bridge.param_set(
+                "JabberID",
+                self.args.jid,
+                "Connection",
+                profile_key=self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set JabberID parameter: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        try:
+            await self.host.bridge.param_set(
+                "Password",
+                self.args.password,
+                "Connection",
+                profile_key=self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set Password parameter: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.disp(
+            f"profile {self.args.profile} successfully created and associated to the new "
+            f"account", 1)
+        self.host.quit()
+
+
+class AccountModify(base.CommandBase):
+    def __init__(self, host):
+        super(AccountModify, self).__init__(
+            host, "modify", help=_("change password for XMPP account")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "password", help=_("new XMPP password")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.in_band_password_change(
+                self.args.password,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't change XMPP password: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class AccountDelete(base.CommandBase):
+    def __init__(self, host):
+        super(AccountDelete, self).__init__(
+            host, "delete", help=_("delete a XMPP account")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("delete account without confirmation"),
+        )
+
+    async def start(self):
+        try:
+            jid_str = await self.host.bridge.param_get_a_async(
+                "JabberID",
+                "Connection",
+                profile_key=self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get JID of the profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        jid_ = jid.JID(jid_str)
+        if not self.args.force:
+            message = (
+                f"You are about to delete the XMPP account with jid {jid_!r}\n"
+                f"This is the XMPP account of profile {self.profile!r}\n"
+                f"Are you sure that you want to delete this account?"
+            )
+            await self.host.confirm_or_quit(message, _("Account deletion cancelled"))
+
+        try:
+            await self.host.bridge.in_band_unregister(jid_.domain, self.args.profile)
+        except Exception as e:
+            self.disp(f"can't delete XMPP account with jid {jid_!r}: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class Account(base.CommandBase):
+    subcommands = (AccountCreate, AccountModify, AccountDelete)
+
+    def __init__(self, host):
+        super(Account, self).__init__(
+            host, "account", use_profile=False, help=("XMPP account management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_adhoc.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.cli import xmlui_manager
+
+__commands__ = ["AdHoc"]
+
+FLAG_LOOP = "LOOP"
+MAGIC_BAREJID = "@PROFILE_BAREJID@"
+
+
+class Remote(base.CommandBase):
+    def __init__(self, host):
+        super(Remote, self).__init__(
+            host, "remote", use_verbose=True, help=_("remote control a software")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("software", type=str, help=_("software name"))
+        self.parser.add_argument(
+            "-j",
+            "--jids",
+            nargs="*",
+            default=[],
+            help=_("jids allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "-g",
+            "--groups",
+            nargs="*",
+            default=[],
+            help=_("groups allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "--forbidden-groups",
+            nargs="*",
+            default=[],
+            help=_("groups that are *NOT* allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "--forbidden-jids",
+            nargs="*",
+            default=[],
+            help=_("jids that are *NOT* allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "-l", "--loop", action="store_true", help=_("loop on the commands")
+        )
+
+    async def start(self):
+        name = self.args.software.lower()
+        flags = []
+        magics = {jid for jid in self.args.jids if jid.count("@") > 1}
+        magics.add(MAGIC_BAREJID)
+        jids = set(self.args.jids).difference(magics)
+        if self.args.loop:
+            flags.append(FLAG_LOOP)
+        try:
+            bus_name, methods = await self.host.bridge.ad_hoc_dbus_add_auto(
+                name,
+                list(jids),
+                self.args.groups,
+                magics,
+                self.args.forbidden_jids,
+                self.args.forbidden_groups,
+                flags,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create remote control: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not bus_name:
+                self.disp(_("No bus name found"), 1)
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(_("Bus name found: [%s]" % bus_name), 1)
+                for method in methods:
+                    path, iface, command = method
+                    self.disp(
+                        _("Command found: (path:{path}, iface: {iface}) [{command}]")
+                        .format(path=path, iface=iface, command=command),
+                        1,
+                    )
+                self.host.quit()
+
+
+class Run(base.CommandBase):
+    """Run an Ad-Hoc command"""
+
+    def __init__(self, host):
+        super(Run, self).__init__(
+            host, "run", use_verbose=True, help=_("run an Ad-Hoc command")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            default="",
+            help=_("jid of the service (default: profile's server"),
+        )
+        self.parser.add_argument(
+            "-S",
+            "--submit",
+            action="append_const",
+            const=xmlui_manager.SUBMIT,
+            dest="workflow",
+            help=_("submit form/page"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="workflow",
+            metavar=("KEY", "VALUE"),
+            help=_("field value"),
+        )
+        self.parser.add_argument(
+            "node",
+            nargs="?",
+            default="",
+            help=_("node of the command (default: list commands)"),
+        )
+
+    async def start(self):
+        try:
+            xmlui_raw = await self.host.bridge.ad_hoc_run(
+                self.args.jid,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            xmlui = xmlui_manager.create(self.host, xmlui_raw)
+            workflow = self.args.workflow
+            await xmlui.show(workflow)
+            if not workflow:
+                if xmlui.type == "form":
+                    await xmlui.submit_form()
+            self.host.quit()
+
+
+class List(base.CommandBase):
+    """List Ad-Hoc commands available on a service"""
+
+    def __init__(self, host):
+        super(List, self).__init__(
+            host, "list", use_verbose=True, help=_("list Ad-Hoc commands of a service")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            default="",
+            help=_("jid of the service (default: profile's server)"),
+        )
+
+    async def start(self):
+        try:
+            xmlui_raw = await self.host.bridge.ad_hoc_list(
+                self.args.jid,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            xmlui = xmlui_manager.create(self.host, xmlui_raw)
+            await xmlui.show(read_only=True)
+            self.host.quit()
+
+
+class AdHoc(base.CommandBase):
+    subcommands = (Run, List, Remote)
+
+    def __init__(self, host):
+        super(AdHoc, self).__init__(
+            host, "ad-hoc", use_profile=False, help=_("Ad-hoc commands")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_application.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli.constants import Const as C
+
+__commands__ = ["Application"]
+
+
+class List(base.CommandBase):
+    """List available applications"""
+
+    def __init__(self, host):
+        super(List, self).__init__(
+            host, "list", use_profile=False, use_output=C.OUTPUT_LIST,
+            help=_("list available applications")
+        )
+
+    def add_parser_options(self):
+        # FIXME: "extend" would be better here, but it's only available from Python 3.8+
+        #   so we use "append" until minimum version of Python is raised.
+        self.parser.add_argument(
+            "-f",
+            "--filter",
+            dest="filters",
+            action="append",
+            choices=["available", "running"],
+            help=_("show applications with this status"),
+        )
+
+    async def start(self):
+
+        # FIXME: this is only needed because we can't use "extend" in
+        #   add_parser_options, see note there
+        if self.args.filters:
+            self.args.filters = list(set(self.args.filters))
+        else:
+            self.args.filters = ['available']
+
+        try:
+            found_apps = await self.host.bridge.applications_list(self.args.filters)
+        except Exception as e:
+            self.disp(f"can't get applications list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(found_apps)
+            self.host.quit()
+
+
+class Start(base.CommandBase):
+    """Start an application"""
+
+    def __init__(self, host):
+        super(Start, self).__init__(
+            host, "start", use_profile=False, help=_("start an application")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "name",
+            help=_("name of the application to start"),
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.application_start(
+                self.args.name,
+                "",
+            )
+        except Exception as e:
+            self.disp(f"can't start {self.args.name}: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Stop(base.CommandBase):
+
+    def __init__(self, host):
+        super(Stop, self).__init__(
+            host, "stop", use_profile=False, help=_("stop a running application")
+        )
+
+    def add_parser_options(self):
+        id_group = self.parser.add_mutually_exclusive_group(required=True)
+        id_group.add_argument(
+            "name",
+            nargs="?",
+            help=_("name of the application to stop"),
+        )
+        id_group.add_argument(
+            "-i",
+            "--id",
+            help=_("identifier of the instance to stop"),
+        )
+
+    async def start(self):
+        try:
+            if self.args.name is not None:
+                args = [self.args.name, "name"]
+            else:
+                args = [self.args.id, "instance"]
+            await self.host.bridge.application_stop(
+                *args,
+                "",
+            )
+        except Exception as e:
+            if self.args.name is not None:
+                self.disp(
+                    f"can't stop application {self.args.name!r}: {e}", error=True)
+            else:
+                self.disp(
+                    f"can't stop application instance with id {self.args.id!r}: {e}",
+                    error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Exposed(base.CommandBase):
+
+    def __init__(self, host):
+        super(Exposed, self).__init__(
+            host, "exposed", use_profile=False, use_output=C.OUTPUT_DICT,
+            help=_("show data exposed by a running application")
+        )
+
+    def add_parser_options(self):
+        id_group = self.parser.add_mutually_exclusive_group(required=True)
+        id_group.add_argument(
+            "name",
+            nargs="?",
+            help=_("name of the application to check"),
+        )
+        id_group.add_argument(
+            "-i",
+            "--id",
+            help=_("identifier of the instance to check"),
+        )
+
+    async def start(self):
+        try:
+            if self.args.name is not None:
+                args = [self.args.name, "name"]
+            else:
+                args = [self.args.id, "instance"]
+            exposed_data_raw = await self.host.bridge.application_exposed_get(
+                *args,
+                "",
+            )
+        except Exception as e:
+            if self.args.name is not None:
+                self.disp(
+                    f"can't get values exposed from application {self.args.name!r}: {e}",
+                    error=True)
+            else:
+                self.disp(
+                    f"can't values exposed from  application instance with id {self.args.id!r}: {e}",
+                    error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            exposed_data = data_format.deserialise(exposed_data_raw)
+            await self.output(exposed_data)
+            self.host.quit()
+
+
+class Application(base.CommandBase):
+    subcommands = (List, Start, Stop, Exposed)
+
+    def __init__(self, host):
+        super(Application, self).__init__(
+            host, "application", use_profile=False, help=_("manage applications"),
+            aliases=['app'],
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_avatar.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import os
+import os.path
+import asyncio
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.backend.tools import config
+from libervia.backend.tools.common import data_format
+
+
+__commands__ = ["Avatar"]
+DISPLAY_CMD = ["xdg-open", "xv", "display", "gwenview", "showtell"]
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        super(Get, self).__init__(
+            host, "get", use_verbose=True, help=_("retrieve avatar of an entity")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--no-cache", action="store_true", help=_("do no use cached values")
+        )
+        self.parser.add_argument(
+            "-s", "--show", action="store_true", help=_("show avatar")
+        )
+        self.parser.add_argument("jid", nargs='?', default='', help=_("entity"))
+
+    async def show_image(self, path):
+        sat_conf = config.parse_main_conf()
+        cmd = config.config_get(sat_conf, C.CONFIG_SECTION, "image_cmd")
+        cmds = [cmd] + DISPLAY_CMD if cmd else DISPLAY_CMD
+        for cmd in cmds:
+            try:
+                process = await asyncio.create_subprocess_exec(cmd, path)
+                ret = await process.wait()
+            except OSError:
+                continue
+
+            if ret in (0, 2):
+                # we can get exit code 2 with display when stopping it with C-c
+                break
+        else:
+            # didn't worked with commands, we try our luck with webbrowser
+            # in some cases, webbrowser can actually open the associated display program.
+            # Note that this may be possibly blocking, depending on the platform and
+            # available browser
+            import webbrowser
+
+            webbrowser.open(path)
+
+    async def start(self):
+        try:
+            avatar_data_raw = await self.host.bridge.avatar_get(
+                self.args.jid,
+                not self.args.no_cache,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve avatar: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        avatar_data = data_format.deserialise(avatar_data_raw, type_check=None)
+
+        if not avatar_data:
+            self.disp(_("No avatar found."), 1)
+            self.host.quit(C.EXIT_NOT_FOUND)
+
+        avatar_path = avatar_data['path']
+
+        self.disp(avatar_path)
+        if self.args.show:
+            await self.show_image(avatar_path)
+
+        self.host.quit()
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        super(Set, self).__init__(
+            host, "set", use_verbose=True,
+            help=_("set avatar of the profile or an entity")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j", "--jid", default='', help=_("entity whose avatar must be changed"))
+        self.parser.add_argument(
+            "image_path", type=str, help=_("path to the image to upload")
+        )
+
+    async def start(self):
+        path = self.args.image_path
+        if not os.path.exists(path):
+            self.disp(_("file {path} doesn't exist!").format(path=repr(path)), error=True)
+            self.host.quit(C.EXIT_BAD_ARG)
+        path = os.path.abspath(path)
+        try:
+            await self.host.bridge.avatar_set(path, self.args.jid, self.profile)
+        except Exception as e:
+            self.disp(f"can't set avatar: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("avatar has been set"), 1)
+            self.host.quit()
+
+
+class Avatar(base.CommandBase):
+    subcommands = (Get, Set)
+
+    def __init__(self, host):
+        super(Avatar, self).__init__(
+            host, "avatar", use_profile=False, help=_("avatar uploading/retrieving")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_blocking.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import json
+import os
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli import common
+from libervia.cli.constants import Const as C
+from . import base
+
+__commands__ = ["Blocking"]
+
+
+class List(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "list",
+            use_output=C.OUTPUT_LIST,
+            help=_("list blocked entities"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            blocked_jids = await self.host.bridge.blocking_list(
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get blocked entities: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(blocked_jids)
+            self.host.quit(C.EXIT_OK)
+
+
+class Block(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "block",
+            help=_("block one or more entities"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "entities",
+            nargs="+",
+            metavar="JID",
+            help=_("JIDs of entities to block"),
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.blocking_block(
+                self.args.entities,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't block entities: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class Unblock(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "unblock",
+            help=_("unblock one or more entities"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "entities",
+            nargs="+",
+            metavar="JID",
+            help=_("JIDs of entities to unblock"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_('when "all" is used, unblock all entities without confirmation'),
+        )
+
+    async def start(self):
+        if self.args.entities == ["all"]:
+            if not self.args.force:
+                await self.host.confirm_or_quit(
+                    _("All entities will be unblocked, are you sure"),
+                    _("unblock cancelled")
+                )
+            self.args.entities.clear()
+        elif self.args.force:
+            self.parser.error(_('--force is only allowed when "all" is used as target'))
+
+        try:
+            await self.host.bridge.blocking_unblock(
+                self.args.entities,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't unblock entities: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class Blocking(base.CommandBase):
+    subcommands = (List, Block, Unblock)
+
+    def __init__(self, host):
+        super().__init__(
+            host, "blocking", use_profile=False, help=_("entities blocking")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_blog.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,1220 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import asyncio
+from asyncio.subprocess import DEVNULL
+from configparser import NoOptionError, NoSectionError
+import json
+import os
+import os.path
+from pathlib import Path
+import re
+import subprocess
+import sys
+import tempfile
+from urllib.parse import urlparse
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.cli import common
+from libervia.cli.constants import Const as C
+
+from . import base, cmd_pubsub
+
+__commands__ = ["Blog"]
+
+SYNTAX_XHTML = "xhtml"
+# extensions to use with known syntaxes
+SYNTAX_EXT = {
+    # FIXME: default syntax doesn't sounds needed, there should always be a syntax set
+    #        by the plugin.
+    "": "txt",  # used when the syntax is not found
+    SYNTAX_XHTML: "xhtml",
+    "markdown": "md",
+}
+
+
+CONF_SYNTAX_EXT = "syntax_ext_dict"
+BLOG_TMP_DIR = "blog"
+# key to remove from metadata tmp file if they exist
+KEY_TO_REMOVE_METADATA = (
+    "id",
+    "content",
+    "content_xhtml",
+    "comments_node",
+    "comments_service",
+    "updated",
+)
+
+URL_REDIRECT_PREFIX = "url_redirect_"
+AIONOTIFY_INSTALL = '"pip install aionotify"'
+MB_KEYS = (
+    "id",
+    "url",
+    "atom_id",
+    "updated",
+    "published",
+    "language",
+    "comments",  # this key is used for all comments* keys
+    "tags",  # this key is used for all tag* keys
+    "author",
+    "author_jid",
+    "author_email",
+    "author_jid_verified",
+    "content",
+    "content_xhtml",
+    "title",
+    "title_xhtml",
+    "extra"
+)
+OUTPUT_OPT_NO_HEADER = "no-header"
+RE_ATTACHMENT_METADATA = re.compile(r"^(?P<key>[a-z_]+)=(?P<value>.*)")
+ALLOWER_ATTACH_MD_KEY = ("desc", "media_type", "external")
+
+
+async def guess_syntax_from_path(host, sat_conf, path):
+    """Return syntax guessed according to filename extension
+
+    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
+    @param path(str): path to the content file
+    @return(unicode): syntax to use
+    """
+    # we first try to guess syntax with extension
+    ext = os.path.splitext(path)[1][1:]  # we get extension without the '.'
+    if ext:
+        for k, v in SYNTAX_EXT.items():
+            if k and ext == v:
+                return k
+
+                # if not found, we use current syntax
+    return await host.bridge.param_get_a("Syntax", "Composition", "value", host.profile)
+
+
+class BlogPublishCommon:
+    """handle common option for publising commands (Set and Edit)"""
+
+    async def get_current_syntax(self):
+        """Retrieve current_syntax
+
+        Use default syntax if --syntax has not been used, else check given syntax.
+        Will set self.default_syntax_used to True if default syntax has been used
+        """
+        if self.args.syntax is None:
+            self.default_syntax_used = True
+            return await self.host.bridge.param_get_a(
+                "Syntax", "Composition", "value", self.profile
+            )
+        else:
+            self.default_syntax_used = False
+            try:
+                syntax = await self.host.bridge.syntax_get(self.args.syntax)
+                self.current_syntax = self.args.syntax = syntax
+            except Exception as e:
+                if e.classname == "NotFound":
+                    self.parser.error(
+                        _("unknown syntax requested ({syntax})").format(
+                            syntax=self.args.syntax
+                        )
+                    )
+                else:
+                    raise e
+        return self.args.syntax
+
+    def add_parser_options(self):
+        self.parser.add_argument("-T", "--title", help=_("title of the item"))
+        self.parser.add_argument(
+            "-t",
+            "--tag",
+            action="append",
+            help=_("tag (category) of your item"),
+        )
+        self.parser.add_argument(
+            "-l",
+            "--language",
+            help=_("language of the item (ISO 639 code)"),
+        )
+
+        self.parser.add_argument(
+            "-a",
+            "--attachment",
+            dest="attachments",
+            nargs="+",
+            help=_(
+                "attachment in the form URL [metadata_name=value]"
+            )
+        )
+
+        comments_group = self.parser.add_mutually_exclusive_group()
+        comments_group.add_argument(
+            "-C",
+            "--comments",
+            action="store_const",
+            const=True,
+            dest="comments",
+            help=_(
+                "enable comments (default: comments not enabled except if they "
+                "already exist)"
+            ),
+        )
+        comments_group.add_argument(
+            "--no-comments",
+            action="store_const",
+            const=False,
+            dest="comments",
+            help=_("disable comments (will remove comments node if it exist)"),
+        )
+
+        self.parser.add_argument(
+            "-S",
+            "--syntax",
+            help=_("syntax to use (default: get profile's default syntax)"),
+        )
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the blog post")
+        )
+        self.parser.add_argument(
+            "--encrypt-for",
+            metavar="JID",
+            action="append",
+            help=_("encrypt a single item for")
+        )
+        self.parser.add_argument(
+            "-X",
+            "--sign",
+            action="store_true",
+            help=_("cryptographically sign the blog post")
+        )
+
+    async def set_mb_data_content(self, content, mb_data):
+        if self.default_syntax_used:
+            # default syntax has been used
+            mb_data["content_rich"] = content
+        elif self.current_syntax == SYNTAX_XHTML:
+            mb_data["content_xhtml"] = content
+        else:
+            mb_data["content_xhtml"] = await self.host.bridge.syntax_convert(
+                content, self.current_syntax, SYNTAX_XHTML, False, self.profile
+            )
+
+    def handle_attachments(self, mb_data: dict) -> None:
+        """Check, validate and add attachments to mb_data"""
+        if self.args.attachments:
+            attachments = []
+            attachment = {}
+            for arg in self.args.attachments:
+                m = RE_ATTACHMENT_METADATA.match(arg)
+                if m is None:
+                    # we should have an URL
+                    url_parsed = urlparse(arg)
+                    if url_parsed.scheme not in ("http", "https"):
+                        self.parser.error(
+                            "invalid URL in --attachment (only http(s) scheme is "
+                            f" accepted): {arg}"
+                        )
+                    if attachment:
+                        # if we hae a new URL, we have a new attachment
+                        attachments.append(attachment)
+                        attachment = {}
+                    attachment["url"] = arg
+                else:
+                    # we should have a metadata
+                    if "url" not in attachment:
+                        self.parser.error(
+                            "you must to specify an URL before any metadata in "
+                            "--attachment"
+                        )
+                    key = m.group("key")
+                    if key not in ALLOWER_ATTACH_MD_KEY:
+                        self.parser.error(
+                            f"invalid metadata key in --attachment: {key!r}"
+                        )
+                    value = m.group("value").strip()
+                    if key == "external":
+                        if not value:
+                            value=True
+                        else:
+                            value = C.bool(value)
+                    attachment[key] = value
+            if attachment:
+                attachments.append(attachment)
+            if attachments:
+                mb_data.setdefault("extra", {})["attachments"] = attachments
+
+    def set_mb_data_from_args(self, mb_data):
+        """set microblog metadata according to command line options
+
+        if metadata already exist, it will be overwritten
+        """
+        if self.args.comments is not None:
+            mb_data["allow_comments"] = self.args.comments
+        if self.args.tag:
+            mb_data["tags"] = self.args.tag
+        if self.args.title is not None:
+            mb_data["title"] = self.args.title
+        if self.args.language is not None:
+            mb_data["language"] = self.args.language
+        if self.args.encrypt:
+            mb_data["encrypted"] = True
+        if self.args.sign:
+            mb_data["signed"] = True
+        if self.args.encrypt_for:
+            mb_data["encrypted_for"] = {"targets": self.args.encrypt_for}
+        self.handle_attachments(mb_data)
+
+
+class Set(base.CommandBase, BlogPublishCommon):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("publish a new blog item or update an existing one"),
+        )
+        BlogPublishCommon.__init__(self)
+
+    def add_parser_options(self):
+        BlogPublishCommon.add_parser_options(self)
+
+    async def start(self):
+        self.current_syntax = await self.get_current_syntax()
+        self.pubsub_item = self.args.item
+        mb_data = {}
+        self.set_mb_data_from_args(mb_data)
+        if self.pubsub_item:
+            mb_data["id"] = self.pubsub_item
+        content = sys.stdin.read()
+        await self.set_mb_data_content(content, mb_data)
+
+        try:
+            item_id = await self.host.bridge.mb_send(
+                self.args.service,
+                self.args.node,
+                data_format.serialise(mb_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(f"Item published with ID {item_id}")
+            self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    TEMPLATE = "blog/articles.html"
+
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output, "fancy": self.fancy_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
+            use_output=C.OUTPUT_COMPLEX,
+            extra_outputs=extra_outputs,
+            help=_("get blog item(s)"),
+        )
+
+    def add_parser_options(self):
+        #  TODO: a key(s) argument to select keys to display
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            action="append",
+            dest="keys",
+            help=_("microblog data key(s) to display (default: depend of verbosity)"),
+        )
+        # TODO: add MAM filters
+
+    def template_data_mapping(self, data):
+        items, blog_items = data
+        blog_items["items"] = items
+        return {"blog_items": blog_items}
+
+    def format_comments(self, item, keys):
+        lines = []
+        for data in item.get("comments", []):
+            lines.append(data["uri"])
+            for k in ("node", "service"):
+                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
+                    header = ""
+                else:
+                    header = f"{C.A_HEADER}comments_{k}: {A.RESET}"
+                lines.append(header + data[k])
+        return "\n".join(lines)
+
+    def format_tags(self, item, keys):
+        tags = item.pop("tags", [])
+        return ", ".join(tags)
+
+    def format_updated(self, item, keys):
+        return common.format_time(item["updated"])
+
+    def format_published(self, item, keys):
+        return common.format_time(item["published"])
+
+    def format_url(self, item, keys):
+        return uri.build_xmpp_uri(
+            "pubsub",
+            subtype="microblog",
+            path=self.metadata["service"],
+            node=self.metadata["node"],
+            item=item["id"],
+        )
+
+    def get_keys(self):
+        """return keys to display according to verbosity or explicit key request"""
+        verbosity = self.args.verbose
+        if self.args.keys:
+            if not set(MB_KEYS).issuperset(self.args.keys):
+                self.disp(
+                    "following keys are invalid: {invalid}.\n"
+                    "Valid keys are: {valid}.".format(
+                        invalid=", ".join(set(self.args.keys).difference(MB_KEYS)),
+                        valid=", ".join(sorted(MB_KEYS)),
+                    ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+            return self.args.keys
+        else:
+            if verbosity == 0:
+                return ("title", "content")
+            elif verbosity == 1:
+                return (
+                    "title",
+                    "tags",
+                    "author",
+                    "author_jid",
+                    "author_email",
+                    "author_jid_verified",
+                    "published",
+                    "updated",
+                    "content",
+                )
+            else:
+                return MB_KEYS
+
+    def default_output(self, data):
+        """simple key/value output"""
+        items, self.metadata = data
+        keys = self.get_keys()
+
+        #  k_cb use format_[key] methods for complex formattings
+        k_cb = {}
+        for k in keys:
+            try:
+                callback = getattr(self, "format_" + k)
+            except AttributeError:
+                pass
+            else:
+                k_cb[k] = callback
+        for idx, item in enumerate(items):
+            for k in keys:
+                if k not in item and k not in k_cb:
+                    continue
+                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
+                    header = ""
+                else:
+                    header = "{k_fmt}{key}:{k_fmt_e} {sep}".format(
+                        k_fmt=C.A_HEADER,
+                        key=k,
+                        k_fmt_e=A.RESET,
+                        sep="\n" if "content" in k else "",
+                    )
+                value = k_cb[k](item, keys) if k in k_cb else item[k]
+                if isinstance(value, bool):
+                    value = str(value).lower()
+                elif isinstance(value, dict):
+                    value = repr(value)
+                self.disp(header + (value or ""))
+                # we want a separation line after each item but the last one
+            if idx < len(items) - 1:
+                print("")
+
+    def fancy_output(self, data):
+        """display blog is a nice to read way
+
+        this output doesn't use keys filter
+        """
+        # thanks to http://stackoverflow.com/a/943921
+        rows, columns = list(map(int, os.popen("stty size", "r").read().split()))
+        items, metadata = data
+        verbosity = self.args.verbose
+        sep = A.color(A.FG_BLUE, columns * "▬")
+        if items:
+            print(("\n" + sep + "\n"))
+
+        for idx, item in enumerate(items):
+            title = item.get("title")
+            if verbosity > 0:
+                author = item["author"]
+                published, updated = item["published"], item.get("updated")
+            else:
+                author = published = updated = None
+            if verbosity > 1:
+                tags = item.pop("tags", [])
+            else:
+                tags = None
+            content = item.get("content")
+
+            if title:
+                print((A.color(A.BOLD, A.FG_CYAN, item["title"])))
+            meta = []
+            if author:
+                meta.append(A.color(A.FG_YELLOW, author))
+            if published:
+                meta.append(A.color(A.FG_YELLOW, "on ", common.format_time(published)))
+            if updated != published:
+                meta.append(
+                    A.color(A.FG_YELLOW, "(updated on ", common.format_time(updated), ")")
+                )
+            print((" ".join(meta)))
+            if tags:
+                print((A.color(A.FG_MAGENTA, ", ".join(tags))))
+            if (title or tags) and content:
+                print("")
+            if content:
+                self.disp(content)
+
+            print(("\n" + sep + "\n"))
+
+    async def start(self):
+        try:
+            mb_data = data_format.deserialise(
+                await self.host.bridge.mb_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't get blog items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            items = mb_data.pop("items")
+            await self.output((items, mb_data))
+            self.host.quit(C.EXIT_OK)
+
+
+class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            use_draft=True,
+            use_verbose=True,
+            help=_("edit an existing or new blog post"),
+        )
+        BlogPublishCommon.__init__(self)
+        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
+
+    def add_parser_options(self):
+        BlogPublishCommon.add_parser_options(self)
+        self.parser.add_argument(
+            "-P",
+            "--preview",
+            action="store_true",
+            help=_("launch a blog preview in parallel"),
+        )
+        self.parser.add_argument(
+            "--no-publish",
+            action="store_true",
+            help=_('add "publish: False" to metadata'),
+        )
+
+    def build_metadata_file(self, content_file_path, mb_data=None):
+        """Build a metadata file using json
+
+        The file is named after content_file_path, with extension replaced by
+        _metadata.json
+        @param content_file_path(str): path to the temporary file which will contain the
+            body
+        @param mb_data(dict, None): microblog metadata (for existing items)
+        @return (tuple[dict, Path]): merged metadata put originaly in metadata file
+            and path to temporary metadata file
+        """
+        # we first construct metadata from edited item ones and CLI argumments
+        # or re-use the existing one if it exists
+        meta_file_path = content_file_path.with_name(
+            content_file_path.stem + common.METADATA_SUFF
+        )
+        if meta_file_path.exists():
+            self.disp("Metadata file already exists, we re-use it")
+            try:
+                with meta_file_path.open("rb") as f:
+                    mb_data = json.load(f)
+            except (OSError, IOError, ValueError) as e:
+                self.disp(
+                    f"Can't read existing metadata file at {meta_file_path}, "
+                    f"aborting: {e}",
+                    error=True,
+                )
+                self.host.quit(1)
+        else:
+            mb_data = {} if mb_data is None else mb_data.copy()
+
+            # in all cases, we want to remove unwanted keys
+        for key in KEY_TO_REMOVE_METADATA:
+            try:
+                del mb_data[key]
+            except KeyError:
+                pass
+                # and override metadata with command-line arguments
+        self.set_mb_data_from_args(mb_data)
+
+        if self.args.no_publish:
+            mb_data["publish"] = False
+
+            # then we create the file and write metadata there, as JSON dict
+            # XXX: if we port libervia-cli one day on Windows, O_BINARY may need to be
+            #   added here
+        with os.fdopen(
+            os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b"
+        ) as f:
+            # we need to use an intermediate unicode buffer to write to the file
+            # unicode without escaping characters
+            unicode_dump = json.dumps(
+                mb_data,
+                ensure_ascii=False,
+                indent=4,
+                separators=(",", ": "),
+                sort_keys=True,
+            )
+            f.write(unicode_dump.encode("utf-8"))
+
+        return mb_data, meta_file_path
+
+    async def edit(self, content_file_path, content_file_obj, mb_data=None):
+        """Edit the file contening the content using editor, and publish it"""
+        # we first create metadata file
+        meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data)
+
+        coroutines = []
+
+        # do we need a preview ?
+        if self.args.preview:
+            self.disp("Preview requested, launching it", 1)
+            # we redirect outputs to /dev/null to avoid console pollution in editor
+            # if user wants to see messages, (s)he can call "blog preview" directly
+            coroutines.append(
+                asyncio.create_subprocess_exec(
+                    sys.argv[0],
+                    "blog",
+                    "preview",
+                    "--inotify",
+                    "true",
+                    "-p",
+                    self.profile,
+                    str(content_file_path),
+                    stdout=DEVNULL,
+                    stderr=DEVNULL,
+                )
+            )
+
+            # we launch editor
+        coroutines.append(
+            self.run_editor(
+                "blog_editor_args",
+                content_file_path,
+                content_file_obj,
+                meta_file_path=meta_file_path,
+                meta_ori=meta_ori,
+            )
+        )
+
+        await asyncio.gather(*coroutines)
+
+    async def publish(self, content, mb_data):
+        await self.set_mb_data_content(content, mb_data)
+
+        if self.pubsub_item:
+            mb_data["id"] = self.pubsub_item
+
+        mb_data = data_format.serialise(mb_data)
+
+        await self.host.bridge.mb_send(
+            self.pubsub_service, self.pubsub_node, mb_data, self.profile
+        )
+        self.disp("Blog item published")
+
+    def get_tmp_suff(self):
+        # we get current syntax to determine file extension
+        return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""])
+
+    async def get_item_data(self, service, node, item):
+        items = [item] if item else []
+
+        mb_data = data_format.deserialise(
+            await self.host.bridge.mb_get(
+                service, node, 1, items, data_format.serialise({}), self.profile
+            )
+        )
+        item = mb_data["items"][0]
+
+        try:
+            content = item["content_xhtml"]
+        except KeyError:
+            content = item["content"]
+            if content:
+                content = await self.host.bridge.syntax_convert(
+                    content, "text", SYNTAX_XHTML, False, self.profile
+                )
+
+        if content and self.current_syntax != SYNTAX_XHTML:
+            content = await self.host.bridge.syntax_convert(
+                content, SYNTAX_XHTML, self.current_syntax, False, self.profile
+            )
+
+        if content and self.current_syntax == SYNTAX_XHTML:
+            content = content.strip()
+            if not content.startswith("<div>"):
+                content = "<div>" + content + "</div>"
+            try:
+                from lxml import etree
+            except ImportError:
+                self.disp(_("You need lxml to edit pretty XHTML"))
+            else:
+                parser = etree.XMLParser(remove_blank_text=True)
+                root = etree.fromstring(content, parser)
+                content = etree.tostring(root, encoding=str, pretty_print=True)
+
+        return content, item, item["id"]
+
+    async def start(self):
+        # if there are user defined extension, we use them
+        SYNTAX_EXT.update(
+            config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
+        )
+        self.current_syntax = await self.get_current_syntax()
+
+        (
+            self.pubsub_service,
+            self.pubsub_node,
+            self.pubsub_item,
+            content_file_path,
+            content_file_obj,
+            mb_data,
+        ) = await self.get_item_path()
+
+        await self.edit(content_file_path, content_file_obj, mb_data=mb_data)
+        self.host.quit()
+
+
+class Rename(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "rename",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("rename an blog item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("new_id", help=_("new item id to use"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.mb_rename(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.new_id,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't rename item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("Item renamed")
+            self.host.quit(C.EXIT_OK)
+
+
+class Repeat(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "repeat",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("repeat (re-publish) a blog item"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            repeat_id = await self.host.bridge.mb_repeat(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't repeat item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if repeat_id:
+                self.disp(f"Item repeated at ID {str(repeat_id)!r}")
+            else:
+                self.disp("Item repeated")
+            self.host.quit(C.EXIT_OK)
+
+
+class Preview(base.CommandBase, common.BaseEdit):
+    # TODO: need to be rewritten with template output
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "preview", use_verbose=True, help=_("preview a blog content")
+        )
+        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--inotify",
+            type=str,
+            choices=("auto", "true", "false"),
+            default="auto",
+            help=_("use inotify to handle preview"),
+        )
+        self.parser.add_argument(
+            "file",
+            nargs="?",
+            default="current",
+            help=_("path to the content file"),
+        )
+
+    async def show_preview(self):
+        # we implement show_preview here so we don't have to import webbrowser and urllib
+        # when preview is not used
+        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
+        self.webbrowser.open_new_tab(url)
+
+    async def _launch_preview_ext(self, cmd_line, opt_name):
+        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
+        args = common.parse_args(
+            self.host, cmd_line, url=url, preview_file=self.preview_file_path
+        )
+        if not args:
+            self.disp(
+                'Couln\'t find command in "{name}", abording'.format(name=opt_name),
+                error=True,
+            )
+            self.host.quit(1)
+        subprocess.Popen(args)
+
+    async def open_preview_ext(self):
+        await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd")
+
+    async def update_preview_ext(self):
+        await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd")
+
+    async def update_content(self):
+        with self.content_file_path.open("rb") as f:
+            content = f.read().decode("utf-8-sig")
+            if content and self.syntax != SYNTAX_XHTML:
+                # we use safe=True because we want to have a preview as close as possible
+                # to what the people will see
+                content = await self.host.bridge.syntax_convert(
+                    content, self.syntax, SYNTAX_XHTML, True, self.profile
+                )
+
+        xhtml = (
+            f'<html xmlns="http://www.w3.org/1999/xhtml">'
+            f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'
+            f"</head>"
+            f"<body>{content}</body>"
+            f"</html>"
+        )
+
+        with open(self.preview_file_path, "wb") as f:
+            f.write(xhtml.encode("utf-8"))
+
+    async def start(self):
+        import webbrowser
+        import urllib.request, urllib.parse, urllib.error
+
+        self.webbrowser, self.urllib = webbrowser, urllib
+
+        if self.args.inotify != "false":
+            try:
+                import aionotify
+
+            except ImportError:
+                if self.args.inotify == "auto":
+                    aionotify = None
+                    self.disp(
+                        f"aionotify module not found, deactivating feature. You can "
+                        f"install it with {AIONOTIFY_INSTALL}"
+                    )
+                else:
+                    self.disp(
+                        f"aioinotify not found, can't activate the feature! Please "
+                        f"install it with {AIONOTIFY_INSTALL}",
+                        error=True,
+                    )
+                    self.host.quit(1)
+        else:
+            aionotify = None
+
+        sat_conf = self.sat_conf
+        SYNTAX_EXT.update(
+            config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
+        )
+
+        try:
+            self.open_cb_cmd = config.config_get(
+                sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception
+            )
+        except (NoOptionError, NoSectionError):
+            self.open_cb_cmd = None
+            open_cb = self.show_preview
+        else:
+            open_cb = self.open_preview_ext
+
+        self.update_cb_cmd = config.config_get(
+            sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd
+        )
+        if self.update_cb_cmd is None:
+            update_cb = self.show_preview
+        else:
+            update_cb = self.update_preview_ext
+
+            # which file do we need to edit?
+        if self.args.file == "current":
+            self.content_file_path = self.get_current_file(self.profile)
+        else:
+            try:
+                self.content_file_path = Path(self.args.file).resolve(strict=True)
+            except FileNotFoundError:
+                self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file))
+                self.host.quit(C.EXIT_NOT_FOUND)
+
+        self.syntax = await guess_syntax_from_path(
+            self.host, sat_conf, self.content_file_path
+        )
+
+        # at this point the syntax is converted, we can display the preview
+        preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False)
+        self.preview_file_path = preview_file.name
+        preview_file.close()
+        await self.update_content()
+
+        if aionotify is None:
+            # XXX: we don't delete file automatically because browser needs it
+            #      (and webbrowser.open can return before it is read)
+            self.disp(
+                f"temporary file created at {self.preview_file_path}\nthis file will NOT "
+                f"BE DELETED AUTOMATICALLY, please delete it yourself when you have "
+                f"finished"
+            )
+            await open_cb()
+        else:
+            await open_cb()
+            watcher = aionotify.Watcher()
+            watcher_kwargs = {
+                # Watcher don't accept Path so we convert to string
+                "path": str(self.content_file_path),
+                "alias": "content_file",
+                "flags": aionotify.Flags.CLOSE_WRITE
+                | aionotify.Flags.DELETE_SELF
+                | aionotify.Flags.MOVE_SELF,
+            }
+            watcher.watch(**watcher_kwargs)
+
+            loop = asyncio.get_event_loop()
+            await watcher.setup(loop)
+
+            try:
+                while True:
+                    event = await watcher.get_event()
+                    self.disp("Content updated", 1)
+                    if event.flags & (
+                        aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF
+                    ):
+                        self.disp(
+                            "DELETE/MOVE event catched, changing the watch",
+                            2,
+                        )
+                        try:
+                            watcher.unwatch("content_file")
+                        except IOError as e:
+                            self.disp(
+                                f"Can't remove the watch: {e}",
+                                2,
+                            )
+                        watcher = aionotify.Watcher()
+                        watcher.watch(**watcher_kwargs)
+                        try:
+                            await watcher.setup(loop)
+                        except OSError:
+                            # if the new file is not here yet we can have an error
+                            # as a workaround, we do a little rest and try again
+                            await asyncio.sleep(1)
+                            await watcher.setup(loop)
+                    await self.update_content()
+                    await update_cb()
+            except FileNotFoundError:
+                self.disp("The file seems to have been deleted.", error=True)
+                self.host.quit(C.EXIT_NOT_FOUND)
+            finally:
+                os.unlink(self.preview_file_path)
+                try:
+                    watcher.unwatch("content_file")
+                except IOError as e:
+                    self.disp(
+                        f"Can't remove the watch: {e}",
+                        2,
+                    )
+
+
+class Import(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "import",
+            use_pubsub=True,
+            use_progress=True,
+            help=_("import an external blog"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "importer",
+            nargs="?",
+            help=_("importer name, nothing to display importers list"),
+        )
+        self.parser.add_argument("--host", help=_("original blog host"))
+        self.parser.add_argument(
+            "--no-images-upload",
+            action="store_true",
+            help=_("do *NOT* upload images (default: do upload images)"),
+        )
+        self.parser.add_argument(
+            "--upload-ignore-host",
+            help=_("do not upload images from this host (default: upload all images)"),
+        )
+        self.parser.add_argument(
+            "--ignore-tls-errors",
+            action="store_true",
+            help=_("ignore invalide TLS certificate for uploads"),
+        )
+        self.parser.add_argument(
+            "-o",
+            "--option",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar=("NAME", "VALUE"),
+            help=_("importer specific options (see importer description)"),
+        )
+        self.parser.add_argument(
+            "location",
+            nargs="?",
+            help=_(
+                "importer data location (see importer description), nothing to show "
+                "importer description"
+            ),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("Blog upload started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("Blog uploaded successfully"), 2)
+        redirections = {
+            k[len(URL_REDIRECT_PREFIX) :]: v
+            for k, v in metadata.items()
+            if k.startswith(URL_REDIRECT_PREFIX)
+        }
+        if redirections:
+            conf = "\n".join(
+                [
+                    "url_redirections_dict = {}".format(
+                        # we need to add ' ' before each new line
+                        # and to double each '%' for ConfigParser
+                        "\n ".join(
+                            json.dumps(redirections, indent=1, separators=(",", ": "))
+                            .replace("%", "%%")
+                            .split("\n")
+                        )
+                    ),
+                ]
+            )
+            self.disp(
+                _(
+                    "\nTo redirect old URLs to new ones, put the following lines in your"
+                    " sat.conf file, in [libervia] section:\n\n{conf}"
+                ).format(conf=conf)
+            )
+
+    async def on_progress_error(self, error_msg):
+        self.disp(
+            _("Error while uploading blog: {error_msg}").format(error_msg=error_msg),
+            error=True,
+        )
+
+    async def start(self):
+        if self.args.location is None:
+            for name in ("option", "service", "no_images_upload"):
+                if getattr(self.args, name):
+                    self.parser.error(
+                        _(
+                            "{name} argument can't be used without location argument"
+                        ).format(name=name)
+                    )
+            if self.args.importer is None:
+                self.disp(
+                    "\n".join(
+                        [
+                            f"{name}: {desc}"
+                            for name, desc in await self.host.bridge.blogImportList()
+                        ]
+                    )
+                )
+            else:
+                try:
+                    short_desc, long_desc = await self.host.bridge.blogImportDesc(
+                        self.args.importer
+                    )
+                except Exception as e:
+                    msg = [l for l in str(e).split("\n") if l][
+                        -1
+                    ]  # we only keep the last line
+                    self.disp(msg)
+                    self.host.quit(1)
+                else:
+                    self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}")
+            self.host.quit()
+        else:
+            # we have a location, an import is requested
+            options = {key: value for key, value in self.args.option}
+            if self.args.host:
+                options["host"] = self.args.host
+            if self.args.ignore_tls_errors:
+                options["ignore_tls_errors"] = C.BOOL_TRUE
+            if self.args.no_images_upload:
+                options["upload_images"] = C.BOOL_FALSE
+                if self.args.upload_ignore_host:
+                    self.parser.error(
+                        "upload-ignore-host option can't be used when no-images-upload "
+                        "is set"
+                    )
+            elif self.args.upload_ignore_host:
+                options["upload_ignore_host"] = self.args.upload_ignore_host
+
+            try:
+                progress_id = await self.host.bridge.blogImport(
+                    self.args.importer,
+                    self.args.location,
+                    options,
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("Error while trying to import a blog: {e}").format(e=e),
+                    error=True,
+                )
+                self.host.quit(1)
+            else:
+                await self.set_progress_id(progress_id)
+
+
+class AttachmentGet(cmd_pubsub.AttachmentGet):
+
+    def __init__(self, host):
+        super().__init__(host)
+        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
+
+
+    async def start(self):
+        if not self.args.node:
+            namespaces = await self.host.bridge.namespaces_get()
+            try:
+                ns_microblog = namespaces["microblog"]
+            except KeyError:
+                self.disp("XEP-0277 plugin is not loaded", error=True)
+                self.host.quit(C.EXIT_MISSING_FEATURE)
+            else:
+                self.args.node = ns_microblog
+        return await super().start()
+
+
+class AttachmentSet(cmd_pubsub.AttachmentSet):
+
+    def __init__(self, host):
+        super().__init__(host)
+        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
+
+    async def start(self):
+        if not self.args.node:
+            namespaces = await self.host.bridge.namespaces_get()
+            try:
+                ns_microblog = namespaces["microblog"]
+            except KeyError:
+                self.disp("XEP-0277 plugin is not loaded", error=True)
+                self.host.quit(C.EXIT_MISSING_FEATURE)
+            else:
+                self.args.node = ns_microblog
+        return await super().start()
+
+
+class Attachments(base.CommandBase):
+    subcommands = (AttachmentGet, AttachmentSet)
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "attachments",
+            use_profile=False,
+            help=_("set or retrieve blog attachments"),
+        )
+
+
+class Blog(base.CommandBase):
+    subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments)
+
+    def __init__(self, host):
+        super(Blog, self).__init__(
+            host, "blog", use_profile=False, help=_("blog/microblog management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_bookmarks.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+
+__commands__ = ["Bookmarks"]
+
+STORAGE_LOCATIONS = ("local", "private", "pubsub")
+TYPES = ("muc", "url")
+
+
+class BookmarksCommon(base.CommandBase):
+    """Class used to group common options of bookmarks subcommands"""
+
+    def add_parser_options(self, location_default="all"):
+        self.parser.add_argument(
+            "-l",
+            "--location",
+            type=str,
+            choices=(location_default,) + STORAGE_LOCATIONS,
+            default=location_default,
+            help=_("storage location (default: %(default)s)"),
+        )
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            type=str,
+            choices=TYPES,
+            default=TYPES[0],
+            help=_("bookmarks type (default: %(default)s)"),
+        )
+
+
+class BookmarksList(BookmarksCommon):
+    def __init__(self, host):
+        super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks"))
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.bookmarks_list(
+                self.args.type, self.args.location, self.host.profile
+            )
+        except Exception as e:
+            self.disp(f"can't get bookmarks list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        mess = []
+        for location in STORAGE_LOCATIONS:
+            if not data[location]:
+                continue
+            loc_mess = []
+            loc_mess.append(f"{location}:")
+            book_mess = []
+            for book_link, book_data in list(data[location].items()):
+                name = book_data.get("name")
+                autojoin = book_data.get("autojoin", "false") == "true"
+                nick = book_data.get("nick")
+                book_mess.append(
+                    "\t%s[%s%s]%s"
+                    % (
+                        (name + " ") if name else "",
+                        book_link,
+                        " (%s)" % nick if nick else "",
+                        " (*)" if autojoin else "",
+                    )
+                )
+            loc_mess.append("\n".join(book_mess))
+            mess.append("\n".join(loc_mess))
+
+        print("\n\n".join(mess))
+        self.host.quit()
+
+
+class BookmarksRemove(BookmarksCommon):
+    def __init__(self, host):
+        super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark"))
+
+    def add_parser_options(self):
+        super(BookmarksRemove, self).add_parser_options()
+        self.parser.add_argument(
+            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("delete bookmark without confirmation"),
+        )
+
+    async def start(self):
+        if not self.args.force:
+            await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?"))
+
+        try:
+            await self.host.bridge.bookmarks_remove(
+                self.args.type, self.args.bookmark, self.args.location, self.host.profile
+            )
+        except Exception as e:
+            self.disp(_("can't delete bookmark: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("bookmark deleted"))
+            self.host.quit()
+
+
+class BookmarksAdd(BookmarksCommon):
+    def __init__(self, host):
+        super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark"))
+
+    def add_parser_options(self):
+        super(BookmarksAdd, self).add_parser_options(location_default="auto")
+        self.parser.add_argument(
+            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
+        )
+        self.parser.add_argument("-n", "--name", help=_("bookmark name"))
+        muc_group = self.parser.add_argument_group(_("MUC specific options"))
+        muc_group.add_argument("-N", "--nick", help=_("nickname"))
+        muc_group.add_argument(
+            "-a",
+            "--autojoin",
+            action="store_true",
+            help=_("join room on profile connection"),
+        )
+
+    async def start(self):
+        if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None):
+            self.parser.error(_("You can't use --autojoin or --nick with --type url"))
+        data = {}
+        if self.args.autojoin:
+            data["autojoin"] = "true"
+        if self.args.nick is not None:
+            data["nick"] = self.args.nick
+        if self.args.name is not None:
+            data["name"] = self.args.name
+        try:
+            await self.host.bridge.bookmarks_add(
+                self.args.type,
+                self.args.bookmark,
+                data,
+                self.args.location,
+                self.host.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't add bookmark: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("bookmark successfully added"))
+            self.host.quit()
+
+
+class Bookmarks(base.CommandBase):
+    subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd)
+
+    def __init__(self, host):
+        super(Bookmarks, self).__init__(
+            host, "bookmarks", use_profile=False, help=_("manage bookmarks")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_debug.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+import json
+
+__commands__ = ["Debug"]
+
+
+class BridgeCommon(object):
+    def eval_args(self):
+        if self.args.arg:
+            try:
+                return eval("[{}]".format(",".join(self.args.arg)))
+            except SyntaxError as e:
+                self.disp(
+                    "Can't evaluate arguments: {mess}\n{text}\n{offset}^".format(
+                        mess=e, text=e.text, offset=" " * (e.offset - 1)
+                    ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+        else:
+            return []
+
+
+class Method(base.CommandBase, BridgeCommon):
+    def __init__(self, host):
+        base.CommandBase.__init__(self, host, "method", help=_("call a bridge method"))
+        BridgeCommon.__init__(self)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "method", type=str, help=_("name of the method to execute")
+        )
+        self.parser.add_argument("arg", nargs="*", help=_("argument of the method"))
+
+    async def start(self):
+        method = getattr(self.host.bridge, self.args.method)
+        import inspect
+
+        argspec = inspect.getargspec(method)
+
+        kwargs = {}
+        if "profile_key" in argspec.args:
+            kwargs["profile_key"] = self.profile
+        elif "profile" in argspec.args:
+            kwargs["profile"] = self.profile
+
+        args = self.eval_args()
+
+        try:
+            ret = await method(
+                *args,
+                **kwargs,
+            )
+        except Exception as e:
+            self.disp(
+                _("Error while executing {method}: {e}").format(
+                    method=self.args.method, e=e
+                ),
+                error=True,
+            )
+            self.host.quit(C.EXIT_ERROR)
+        else:
+            if ret is not None:
+                self.disp(str(ret))
+            self.host.quit()
+
+
+class Signal(base.CommandBase, BridgeCommon):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "signal", help=_("send a fake signal from backend")
+        )
+        BridgeCommon.__init__(self)
+
+    def add_parser_options(self):
+        self.parser.add_argument("signal", type=str, help=_("name of the signal to send"))
+        self.parser.add_argument("arg", nargs="*", help=_("argument of the signal"))
+
+    async def start(self):
+        args = self.eval_args()
+        json_args = json.dumps(args)
+        # XXX: we use self.args.profile and not self.profile
+        #      because we want the raw profile_key (so plugin handle C.PROF_KEY_NONE)
+        try:
+            await self.host.bridge.debug_signal_fake(
+                self.args.signal, json_args, self.args.profile
+            )
+        except Exception as e:
+            self.disp(_("Can't send fake signal: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_ERROR)
+        else:
+            self.host.quit()
+
+
+class bridge(base.CommandBase):
+    subcommands = (Method, Signal)
+
+    def __init__(self, host):
+        super(bridge, self).__init__(
+            host, "bridge", use_profile=False, help=_("bridge s(t)imulation")
+        )
+
+
+class Monitor(base.CommandBase):
+    def __init__(self, host):
+        super(Monitor, self).__init__(
+            host,
+            "monitor",
+            use_verbose=True,
+            use_profile=False,
+            use_output=C.OUTPUT_XML,
+            help=_("monitor XML stream"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-d",
+            "--direction",
+            choices=("in", "out", "both"),
+            default="both",
+            help=_("stream direction filter"),
+        )
+
+    async def print_xml(self, direction, xml_data, profile):
+        if self.args.direction == "in" and direction != "IN":
+            return
+        if self.args.direction == "out" and direction != "OUT":
+            return
+        verbosity = self.host.verbosity
+        if not xml_data.strip():
+            if verbosity <= 2:
+                return
+            whiteping = True
+        else:
+            whiteping = False
+
+        if verbosity:
+            profile_disp = f" ({profile})" if verbosity > 1 else ""
+            if direction == "IN":
+                self.disp(
+                    A.color(
+                        A.BOLD, A.FG_YELLOW, "<<<===== IN ====", A.FG_WHITE, profile_disp
+                    )
+                )
+            else:
+                self.disp(
+                    A.color(
+                        A.BOLD, A.FG_CYAN, "==== OUT ====>>>", A.FG_WHITE, profile_disp
+                    )
+                )
+        if whiteping:
+            self.disp("[WHITESPACE PING]")
+        else:
+            try:
+                await self.output(xml_data)
+            except Exception:
+                #  initial stream is not valid XML,
+                # in this case we print directly to data
+                #  FIXME: we should test directly lxml.etree.XMLSyntaxError
+                #        but importing lxml directly here is not clean
+                #        should be wrapped in a custom Exception
+                self.disp(xml_data)
+                self.disp("")
+
+    async def start(self):
+        self.host.bridge.register_signal("xml_log", self.print_xml, "plugin")
+
+
+class Theme(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "theme", help=_("print colours used with your background")
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        print(f"background currently used: {A.BOLD}{self.host.background}{A.RESET}\n")
+        for attr in dir(C):
+            if not attr.startswith("A_"):
+                continue
+            color = getattr(C, attr)
+            if attr == "A_LEVEL_COLORS":
+                # This constant contains multiple colors
+                self.disp("LEVEL COLORS: ", end=" ")
+                for idx, c in enumerate(color):
+                    last = idx == len(color) - 1
+                    end = "\n" if last else " "
+                    self.disp(
+                        c + f"LEVEL_{idx}" + A.RESET + (", " if not last else ""), end=end
+                    )
+            else:
+                text = attr[2:]
+                self.disp(A.color(color, text))
+        self.host.quit()
+
+
+class Debug(base.CommandBase):
+    subcommands = (bridge, Monitor, Theme)
+
+    def __init__(self, host):
+        super(Debug, self).__init__(
+            host, "debug", use_profile=False, help=_("debugging tools")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_encryption.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.cli import base
+from libervia.cli.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli import xmlui_manager
+
+__commands__ = ["Encryption"]
+
+
+class EncryptionAlgorithms(base.CommandBase):
+
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(EncryptionAlgorithms, self).__init__(
+            host, "algorithms",
+            use_output=C.OUTPUT_LIST_DICT,
+            extra_outputs=extra_outputs,
+            use_profile=False,
+            help=_("show available encryption algorithms"))
+
+    def add_parser_options(self):
+        pass
+
+    def default_output(self, plugins):
+        if not plugins:
+            self.disp(_("No encryption plugin registered!"))
+        else:
+            self.disp(_("Following encryption algorithms are available: {algos}").format(
+                algos=', '.join([p['name'] for p in plugins])))
+
+    async def start(self):
+        try:
+            plugins_ser = await self.host.bridge.encryption_plugins_get()
+            plugins = data_format.deserialise(plugins_ser, type_check=list)
+        except Exception as e:
+            self.disp(f"can't retrieve plugins: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(plugins)
+            self.host.quit()
+
+
+class EncryptionGet(base.CommandBase):
+
+    def __init__(self, host):
+        super(EncryptionGet, self).__init__(
+            host, "get",
+            use_output=C.OUTPUT_DICT,
+            help=_("get encryption session data"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to check")
+        )
+
+    async def start(self):
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        try:
+            serialised = await self.host.bridge.message_encryption_get(jid, self.profile)
+        except Exception as e:
+            self.disp(f"can't get session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        session_data = data_format.deserialise(serialised)
+        if session_data is None:
+            self.disp(
+                "No encryption session found, the messages are sent in plain text.")
+            self.host.quit(C.EXIT_NOT_FOUND)
+        await self.output(session_data)
+        self.host.quit()
+
+
+class EncryptionStart(base.CommandBase):
+
+    def __init__(self, host):
+        super(EncryptionStart, self).__init__(
+            host, "start",
+            help=_("start encrypted session with an entity"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--encrypt-noreplace",
+            action="store_true",
+            help=_("don't replace encryption algorithm if an other one is already used"))
+        algorithm = self.parser.add_mutually_exclusive_group()
+        algorithm.add_argument(
+            "-n", "--name", help=_("algorithm name (DEFAULT: choose automatically)"))
+        algorithm.add_argument(
+            "-N", "--namespace",
+            help=_("algorithm namespace (DEFAULT: choose automatically)"))
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to stop encrypted session with")
+        )
+
+    async def start(self):
+        if self.args.name is not None:
+            try:
+                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
+            except Exception as e:
+                self.disp(f"can't get encryption namespace: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        elif self.args.namespace is not None:
+            namespace = self.args.namespace
+        else:
+            namespace = ""
+
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+
+        try:
+            await self.host.bridge.message_encryption_start(
+                jid, namespace, not self.args.encrypt_noreplace,
+                self.profile)
+        except Exception as e:
+            self.disp(f"can't get encryption namespace: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class EncryptionStop(base.CommandBase):
+
+    def __init__(self, host):
+        super(EncryptionStop, self).__init__(
+            host, "stop",
+            help=_("stop encrypted session with an entity"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to stop encrypted session with")
+        )
+
+    async def start(self):
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        try:
+            await self.host.bridge.message_encryption_stop(jid, self.profile)
+        except Exception as e:
+            self.disp(f"can't end encrypted session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class TrustUI(base.CommandBase):
+
+    def __init__(self, host):
+        super(TrustUI, self).__init__(
+            host, "ui",
+            help=_("get UI to manage trust"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to stop encrypted session with")
+        )
+        algorithm = self.parser.add_mutually_exclusive_group()
+        algorithm.add_argument(
+            "-n", "--name", help=_("algorithm name (DEFAULT: current algorithm)"))
+        algorithm.add_argument(
+            "-N", "--namespace",
+            help=_("algorithm namespace (DEFAULT: current algorithm)"))
+
+    async def start(self):
+        if self.args.name is not None:
+            try:
+                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
+            except Exception as e:
+                self.disp(f"can't get encryption namespace: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        elif self.args.namespace is not None:
+            namespace = self.args.namespace
+        else:
+            namespace = ""
+
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+
+        try:
+            xmlui_raw = await self.host.bridge.encryption_trust_ui_get(
+                jid, namespace, self.profile)
+        except Exception as e:
+            self.disp(f"can't get encryption session trust UI: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        xmlui = xmlui_manager.create(self.host, xmlui_raw)
+        await xmlui.show()
+        if xmlui.type != C.XMLUI_DIALOG:
+            await xmlui.submit_form()
+        self.host.quit()
+
+class EncryptionTrust(base.CommandBase):
+    subcommands = (TrustUI,)
+
+    def __init__(self, host):
+        super(EncryptionTrust, self).__init__(
+            host, "trust", use_profile=False, help=_("trust manangement")
+        )
+
+
+class Encryption(base.CommandBase):
+    subcommands = (EncryptionAlgorithms, EncryptionGet, EncryptionStart, EncryptionStop,
+                   EncryptionTrust)
+
+    def __init__(self, host):
+        super(Encryption, self).__init__(
+            host, "encryption", use_profile=False, help=_("encryption sessions handling")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_event.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,755 @@
+#!/usr/bin/env python3
+
+
+# libervia-cli: Libervia CLI frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import argparse
+import sys
+
+from sqlalchemy import desc
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.cli import common
+from libervia.cli.constants import Const as C
+from libervia.cli.constants import Const as C
+
+from . import base
+
+__commands__ = ["Event"]
+
+OUTPUT_OPT_TABLE = "table"
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
+            use_verbose=True,
+            extra_outputs={
+                "default": self.default_output,
+            },
+            help=_("get event(s) data"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            events_data_s = await self.host.bridge.events_get(
+                self.args.service,
+                self.args.node,
+                self.args.items,
+                self.get_pubsub_extra(),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get events data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            events_data = data_format.deserialise(events_data_s, type_check=list)
+            await self.output(events_data)
+            self.host.quit()
+
+    def default_output(self, events):
+        nb_events = len(events)
+        for idx, event in enumerate(events):
+            names = event["name"]
+            name = names.get("") or next(iter(names.values()))
+            start = event["start"]
+            start_human = date_utils.date_fmt(
+                start, "medium", tz_info=date_utils.TZ_LOCAL
+            )
+            end = event["end"]
+            self.disp(A.color(
+                A.BOLD, start_human, A.RESET, " ",
+                f"({date_utils.delta2human(start, end)}) ",
+                C.A_HEADER, name
+            ))
+            if self.verbosity > 0:
+                descriptions = event.get("descriptions", [])
+                if descriptions:
+                    self.disp(descriptions[0]["description"])
+            if idx < (nb_events-1):
+                self.disp("")
+
+
+class CategoryAction(argparse.Action):
+
+    def __init__(self, option_strings, dest, nargs=None, metavar=None, **kwargs):
+        if nargs is not None or metavar is not None:
+            raise ValueError("nargs and metavar must not be used")
+        if metavar is not None:
+            metavar="TERM WIKIDATA_ID LANG"
+        if "--help" in sys.argv:
+            # FIXME: dirty workaround to have correct --help message
+            #   argparse doesn't normally allow variable number of arguments beside "+"
+            #   and "*", this workaround show METAVAR as 3 arguments were expected, while
+            #   we can actuall use 1, 2 or 3.
+            nargs = 3
+            metavar = ("TERM", "[WIKIDATA_ID]", "[LANG]")
+        else:
+            nargs = "+"
+
+        super().__init__(option_strings, dest, metavar=metavar, nargs=nargs, **kwargs)
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        categories = getattr(namespace, self.dest)
+        if categories is None:
+            categories = []
+            setattr(namespace, self.dest, categories)
+
+        if not values:
+            parser.error("category values must be set")
+
+        category = {
+            "term": values[0]
+        }
+
+        if len(values) == 1:
+            pass
+        elif len(values) == 2:
+            value = values[1]
+            if value.startswith("Q"):
+                category["wikidata_id"] = value
+            else:
+                category["language"] = value
+        elif len(values) == 3:
+            __, wd, lang = values
+            category["wikidata_id"] = wd
+            category["language"] = lang
+        else:
+            parser.error("Category can't have more than 3 arguments")
+
+        categories.append(category)
+
+
+class EventBase:
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-S", "--start", type=base.date_decoder, metavar="TIME_PATTERN",
+            help=_("the start time of the event"))
+        end_group = self.parser.add_mutually_exclusive_group()
+        end_group.add_argument(
+            "-E", "--end", type=base.date_decoder, metavar="TIME_PATTERN",
+            help=_("the time of the end of the event"))
+        end_group.add_argument(
+            "-D", "--duration", help=_("duration of the event"))
+        self.parser.add_argument(
+            "-H", "--head-picture", help="URL to a picture to use as head-picture"
+        )
+        self.parser.add_argument(
+            "-d", "--description", help="plain text description the event"
+        )
+        self.parser.add_argument(
+            "-C", "--category", action=CategoryAction, dest="categories",
+            help="Category of the event"
+        )
+        self.parser.add_argument(
+            "-l", "--location", action="append", nargs="+", metavar="[KEY] VALUE",
+            help="Location metadata"
+        )
+        rsvp_group = self.parser.add_mutually_exclusive_group()
+        rsvp_group.add_argument(
+            "--rsvp", action="store_true", help=_("RSVP is requested"))
+        rsvp_group.add_argument(
+            "--rsvp_json", metavar="JSON", help=_("JSON description of RSVP form"))
+        for node_type in ("invitees", "comments", "blog", "schedule"):
+            self.parser.add_argument(
+                f"--{node_type}",
+                nargs=2,
+                metavar=("JID", "NODE"),
+                help=_("link {node_type} pubsub node").format(node_type=node_type)
+            )
+        self.parser.add_argument(
+            "-a", "--attachment", action="append", dest="attachments",
+            help=_("attach a file")
+        )
+        self.parser.add_argument("--website", help=_("website of the event"))
+        self.parser.add_argument(
+            "--status", choices=["confirmed", "tentative", "cancelled"],
+            help=_("status of the event")
+        )
+        self.parser.add_argument(
+            "-T", "--language", metavar="LANG", action="append", dest="languages",
+            help=_("main languages spoken at the event")
+        )
+        self.parser.add_argument(
+            "--wheelchair", choices=["full", "partial", "no"],
+            help=_("is the location accessible by wheelchair")
+        )
+        self.parser.add_argument(
+            "--external",
+            nargs=3,
+            metavar=("JID", "NODE", "ITEM"),
+            help=_("link to an external event")
+        )
+
+    def get_event_data(self):
+        if self.args.duration is not None:
+            if self.args.start is None:
+                self.parser.error("--start must be send if --duration is used")
+            # if duration is used, we simply add it to start time to get end time
+            self.args.end = base.date_decoder(f"{self.args.start} + {self.args.duration}")
+
+        event = {}
+        if self.args.name is not None:
+            event["name"] = {"": self.args.name}
+
+        if self.args.start is not None:
+            event["start"] = self.args.start
+
+        if self.args.end is not None:
+            event["end"] = self.args.end
+
+        if self.args.head_picture:
+            event["head-picture"] = {
+                "sources": [{
+                    "url": self.args.head_picture
+                }]
+            }
+        if self.args.description:
+            event["descriptions"] = [
+                {
+                    "type": "text",
+                    "description": self.args.description
+                }
+            ]
+        if self.args.categories:
+            event["categories"] = self.args.categories
+        if self.args.location is not None:
+            location = {}
+            for location_data in self.args.location:
+                if len(location_data) == 1:
+                    location["description"] = location_data[0]
+                else:
+                    key, *values = location_data
+                    location[key] = " ".join(values)
+            event["locations"] = [location]
+
+        if self.args.rsvp:
+            event["rsvp"] = [{}]
+        elif self.args.rsvp_json:
+            if isinstance(self.args.rsvp_elt, dict):
+                event["rsvp"] = [self.args.rsvp_json]
+            else:
+                event["rsvp"] = self.args.rsvp_json
+
+        for node_type in ("invitees", "comments", "blog", "schedule"):
+            value = getattr(self.args, node_type)
+            if value:
+                service, node = value
+                event[node_type] = {"service": service, "node": node}
+
+        if self.args.attachments:
+            attachments = event["attachments"] = []
+            for attachment in self.args.attachments:
+                attachments.append({
+                    "sources": [{"url": attachment}]
+                })
+
+        extra = {}
+
+        for arg in ("website", "status", "languages"):
+            value = getattr(self.args, arg)
+            if value is not None:
+                extra[arg] = value
+        if self.args.wheelchair is not None:
+            extra["accessibility"] = {"wheelchair": self.args.wheelchair}
+
+        if extra:
+            event["extra"] = extra
+
+        if self.args.external:
+            ext_jid, ext_node, ext_item = self.args.external
+            event["external"] = {
+                "jid": ext_jid,
+                "node": ext_node,
+                "item": ext_item
+            }
+        return event
+
+
+class Create(EventBase, base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "create",
+            use_pubsub=True,
+            help=_("create or replace event"),
+        )
+
+    def add_parser_options(self):
+        super().add_parser_options()
+        self.parser.add_argument(
+            "-i",
+            "--id",
+            default="",
+            help=_("ID of the PubSub Item"),
+        )
+        # name is mandatory here
+        self.parser.add_argument("name", help=_("name of the event"))
+
+    async def start(self):
+        if self.args.start is None:
+            self.parser.error("--start must be set")
+        event_data = self.get_event_data()
+        # we check self.args.end after get_event_data because it may be set there id
+        # --duration is used
+        if self.args.end is None:
+            self.parser.error("--end or --duration must be set")
+        try:
+            await self.host.bridge.event_create(
+                data_format.serialise(event_data),
+                self.args.id,
+                self.args.node,
+                self.args.service,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create event: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("Event created successfuly)"))
+            self.host.quit()
+
+
+class Modify(EventBase, base.CommandBase):
+    def __init__(self, host):
+        super(Modify, self).__init__(
+            host,
+            "modify",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("modify an existing event"),
+        )
+        EventBase.__init__(self)
+
+    def add_parser_options(self):
+        super().add_parser_options()
+        # name is optional here
+        self.parser.add_argument("-N", "--name", help=_("name of the event"))
+
+    async def start(self):
+        event_data = self.get_event_data()
+        try:
+            await self.host.bridge.event_modify(
+                data_format.serialise(event_data),
+                self.args.item,
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't update event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class InviteeGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            use_verbose=True,
+            help=_("get event attendance"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j", "--jid", action="append", dest="jids", default=[],
+            help=_("only retrieve RSVP from those JIDs")
+        )
+
+    async def start(self):
+        try:
+            event_data_s = await self.host.bridge.event_invitee_get(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.jids,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            event_data = data_format.deserialise(event_data_s)
+            await self.output(event_data)
+            self.host.quit()
+
+
+class InviteeSet(base.CommandBase):
+    def __init__(self, host):
+        super(InviteeSet, self).__init__(
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("set event attendance"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set"),
+        )
+
+    async def start(self):
+        # TODO: handle RSVP with XMLUI in a similar way as for `ad-hoc run`
+        fields = dict(self.args.fields) if self.args.fields else {}
+        try:
+            self.host.bridge.event_invitee_set(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                data_format.serialise(fields),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class InviteesList(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "list",
+            use_output=C.OUTPUT_DICT_DICT,
+            extra_outputs=extra_outputs,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("get event attendance"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-m",
+            "--missing",
+            action="store_true",
+            help=_("show missing people (invited but no R.S.V.P. so far)"),
+        )
+        self.parser.add_argument(
+            "-R",
+            "--no-rsvp",
+            action="store_true",
+            help=_("don't show people which gave R.S.V.P."),
+        )
+
+    def _attend_filter(self, attend, row):
+        if attend == "yes":
+            attend_color = C.A_SUCCESS
+        elif attend == "no":
+            attend_color = C.A_FAILURE
+        else:
+            attend_color = A.FG_WHITE
+        return A.color(attend_color, attend)
+
+    def _guests_filter(self, guests):
+        return "(" + str(guests) + ")" if guests else ""
+
+    def default_output(self, event_data):
+        data = []
+        attendees_yes = 0
+        attendees_maybe = 0
+        attendees_no = 0
+        attendees_missing = 0
+        guests = 0
+        guests_maybe = 0
+        for jid_, jid_data in event_data.items():
+            jid_data["jid"] = jid_
+            try:
+                guests_int = int(jid_data["guests"])
+            except (ValueError, KeyError):
+                pass
+            attend = jid_data.get("attend", "")
+            if attend == "yes":
+                attendees_yes += 1
+                guests += guests_int
+            elif attend == "maybe":
+                attendees_maybe += 1
+                guests_maybe += guests_int
+            elif attend == "no":
+                attendees_no += 1
+                jid_data["guests"] = ""
+            else:
+                attendees_missing += 1
+                jid_data["guests"] = ""
+            data.append(jid_data)
+
+        show_table = OUTPUT_OPT_TABLE in self.args.output_opts
+
+        table = common.Table.from_list_dict(
+            self.host,
+            data,
+            ("nick",) + (("jid",) if self.host.verbosity else ()) + ("attend", "guests"),
+            headers=None,
+            filters={
+                "nick": A.color(C.A_HEADER, "{}" if show_table else "{} "),
+                "jid": "{}" if show_table else "{} ",
+                "attend": self._attend_filter,
+                "guests": "{}" if show_table else self._guests_filter,
+            },
+            defaults={"nick": "", "attend": "", "guests": 1},
+        )
+        if show_table:
+            table.display()
+        else:
+            table.display_blank(show_header=False, col_sep="")
+
+        if not self.args.no_rsvp:
+            self.disp("")
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("Attendees: "),
+                    A.RESET,
+                    str(len(data)),
+                    _(" ("),
+                    C.A_SUCCESS,
+                    _("yes: "),
+                    str(attendees_yes),
+                    A.FG_WHITE,
+                    _(", maybe: "),
+                    str(attendees_maybe),
+                    ", ",
+                    C.A_FAILURE,
+                    _("no: "),
+                    str(attendees_no),
+                    A.RESET,
+                    ")",
+                )
+            )
+            self.disp(
+                A.color(C.A_SUBHEADER, _("confirmed guests: "), A.RESET, str(guests))
+            )
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("unconfirmed guests: "),
+                    A.RESET,
+                    str(guests_maybe),
+                )
+            )
+            self.disp(
+                A.color(C.A_SUBHEADER, _("total: "), A.RESET, str(guests + guests_maybe))
+            )
+        if attendees_missing:
+            self.disp("")
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("missing people (no reply): "),
+                    A.RESET,
+                    str(attendees_missing),
+                )
+            )
+
+    async def start(self):
+        if self.args.no_rsvp and not self.args.missing:
+            self.parser.error(_("you need to use --missing if you use --no-rsvp"))
+        if not self.args.missing:
+            prefilled = {}
+        else:
+            # we get prefilled data with all people
+            try:
+                affiliations = await self.host.bridge.ps_node_affiliations_get(
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(f"can't get node affiliations: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                # we fill all affiliations with empty data, answered one will be filled
+                # below. We only consider people with "publisher" affiliation as invited,
+                # creators are not, and members can just observe
+                prefilled = {
+                    jid_: {}
+                    for jid_, affiliation in affiliations.items()
+                    if affiliation in ("publisher",)
+                }
+
+        try:
+            event_data = await self.host.bridge.event_invitees_list(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            # we fill nicknames and keep only requested people
+
+        if self.args.no_rsvp:
+            for jid_ in event_data:
+                # if there is a jid in event_data it must be there in prefilled too
+                # otherwie somebody is not on the invitees list
+                try:
+                    del prefilled[jid_]
+                except KeyError:
+                    self.disp(
+                        A.color(
+                            C.A_WARNING,
+                            f"We got a RSVP from somebody who was not in invitees "
+                            f"list: {jid_}",
+                        ),
+                        error=True,
+                    )
+        else:
+            # we replace empty dicts for existing people with R.S.V.P. data
+            prefilled.update(event_data)
+
+            # we get nicknames for everybody, make it easier for organisers
+        for jid_, data in prefilled.items():
+            id_data = await self.host.bridge.identity_get(jid_, [], True, self.profile)
+            id_data = data_format.deserialise(id_data)
+            data["nick"] = id_data["nicknames"][0]
+
+        await self.output(prefilled)
+        self.host.quit()
+
+
+class InviteeInvite(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "invite",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("invite someone to the event through email"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            action="append",
+            default=[],
+            help="email(s) to send the invitation to",
+        )
+        self.parser.add_argument(
+            "-N",
+            "--name",
+            default="",
+            help="name of the invitee",
+        )
+        self.parser.add_argument(
+            "-H",
+            "--host-name",
+            default="",
+            help="name of the host",
+        )
+        self.parser.add_argument(
+            "-l",
+            "--lang",
+            default="",
+            help="main language spoken by the invitee",
+        )
+        self.parser.add_argument(
+            "-U",
+            "--url-template",
+            default="",
+            help="template to construct the URL",
+        )
+        self.parser.add_argument(
+            "-S",
+            "--subject",
+            default="",
+            help="subject of the invitation email (default: generic subject)",
+        )
+        self.parser.add_argument(
+            "-b",
+            "--body",
+            default="",
+            help="body of the invitation email (default: generic body)",
+        )
+
+    async def start(self):
+        email = self.args.email[0] if self.args.email else None
+        emails_extra = self.args.email[1:]
+
+        try:
+            await self.host.bridge.event_invite_by_email(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                email,
+                emails_extra,
+                self.args.name,
+                self.args.host_name,
+                self.args.lang,
+                self.args.url_template,
+                self.args.subject,
+                self.args.body,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Invitee(base.CommandBase):
+    subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite)
+
+    def __init__(self, host):
+        super(Invitee, self).__init__(
+            host, "invitee", use_profile=False, help=_("manage invities")
+        )
+
+
+class Event(base.CommandBase):
+    subcommands = (Get, Create, Modify, Invitee)
+
+    def __init__(self, host):
+        super(Event, self).__init__(
+            host, "event", use_profile=False, help=_("event management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_file.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,1108 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from . import xmlui_manager
+import sys
+import os
+import os.path
+import tarfile
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli.constants import Const as C
+from libervia.cli import common
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import utils
+from urllib.parse import urlparse
+from pathlib import Path
+import tempfile
+import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
+import json
+
+__commands__ = ["File"]
+DEFAULT_DEST = "downloaded_file"
+
+
+class Send(base.CommandBase):
+    def __init__(self, host):
+        super(Send, self).__init__(
+            host,
+            "send",
+            use_progress=True,
+            use_verbose=True,
+            help=_("send a file to a contact"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "files", type=str, nargs="+", metavar="file", help=_("a list of file")
+        )
+        self.parser.add_argument("jid", help=_("the destination jid"))
+        self.parser.add_argument(
+            "-b", "--bz2", action="store_true", help=_("make a bzip2 tarball")
+        )
+        self.parser.add_argument(
+            "-d",
+            "--path",
+            help=("path to the directory where the file must be stored"),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            help=("namespace of the file"),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=("name to use (DEFAULT: use source file name)"),
+        )
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the file transfer")
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File copy started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File sent successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        if error_msg == C.PROGRESS_ERROR_DECLINED:
+            self.disp(_("The file has been refused by your contact"))
+        else:
+            self.disp(_("Error while sending file: {}").format(error_msg), error=True)
+
+    async def got_id(self, data, file_):
+        """Called when a progress id has been received
+
+        @param pid(unicode): progress id
+        @param file_(str): file path
+        """
+        # FIXME: this show progress only for last progress_id
+        self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1)
+        try:
+            await self.set_progress_id(data["progress"])
+        except KeyError:
+            # TODO: if 'xmlui' key is present, manage xmlui message display
+            self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True)
+            self.host.quit(2)
+
+    async def start(self):
+        for file_ in self.args.files:
+            if not os.path.exists(file_):
+                self.disp(
+                    _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+            if not self.args.bz2 and os.path.isdir(file_):
+                self.disp(
+                    _(
+                        "{file_} is a dir! Please send files inside or use compression"
+                    ).format(file_=repr(file_))
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+
+        extra = {}
+        if self.args.path:
+            extra["path"] = self.args.path
+        if self.args.namespace:
+            extra["namespace"] = self.args.namespace
+        if self.args.encrypt:
+            extra["encrypted"] = True
+
+        if self.args.bz2:
+            with tempfile.NamedTemporaryFile("wb", delete=False) as buf:
+                self.host.add_on_quit_callback(os.unlink, buf.name)
+                self.disp(_("bz2 is an experimental option, use with caution"))
+                # FIXME: check free space
+                self.disp(_("Starting compression, please wait..."))
+                sys.stdout.flush()
+                bz2 = tarfile.open(mode="w:bz2", fileobj=buf)
+                archive_name = "{}.tar.bz2".format(
+                    os.path.basename(self.args.files[0]) or "compressed_files"
+                )
+                for file_ in self.args.files:
+                    self.disp(_("Adding {}").format(file_), 1)
+                    bz2.add(file_)
+                bz2.close()
+                self.disp(_("Done !"), 1)
+
+                try:
+                    send_data = await self.host.bridge.file_send(
+                        self.args.jid,
+                        buf.name,
+                        self.args.name or archive_name,
+                        "",
+                        data_format.serialise(extra),
+                        self.profile,
+                    )
+                except Exception as e:
+                    self.disp(f"can't send file: {e}", error=True)
+                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                else:
+                    await self.got_id(send_data, file_)
+        else:
+            for file_ in self.args.files:
+                path = os.path.abspath(file_)
+                try:
+                    send_data = await self.host.bridge.file_send(
+                        self.args.jid,
+                        path,
+                        self.args.name,
+                        "",
+                        data_format.serialise(extra),
+                        self.profile,
+                    )
+                except Exception as e:
+                    self.disp(f"can't send file {file_!r}: {e}", error=True)
+                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                else:
+                    await self.got_id(send_data, file_)
+
+
+class Request(base.CommandBase):
+    def __init__(self, host):
+        super(Request, self).__init__(
+            host,
+            "request",
+            use_progress=True,
+            use_verbose=True,
+            help=_("request a file from a contact"),
+        )
+
+    @property
+    def filename(self):
+        return self.args.name or self.args.hash or "output"
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", help=_("the destination jid"))
+        self.parser.add_argument(
+            "-D",
+            "--dest",
+            help=_(
+                "destination path where the file will be saved (default: "
+                "[current_dir]/[name|hash])"
+            ),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=_("name of the file"),
+        )
+        self.parser.add_argument(
+            "-H",
+            "--hash",
+            default="",
+            help=_("hash of the file"),
+        )
+        self.parser.add_argument(
+            "-a",
+            "--hash-algo",
+            default="sha-256",
+            help=_("hash algorithm use for --hash (default: sha-256)"),
+        )
+        self.parser.add_argument(
+            "-d",
+            "--path",
+            help=("path to the directory containing the file"),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            help=("namespace of the file"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("overwrite existing file without confirmation"),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File copy started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File received successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        if error_msg == C.PROGRESS_ERROR_DECLINED:
+            self.disp(_("The file request has been refused"))
+        else:
+            self.disp(_("Error while requesting file: {}").format(error_msg), error=True)
+
+    async def start(self):
+        if not self.args.name and not self.args.hash:
+            self.parser.error(_("at least one of --name or --hash must be provided"))
+        if self.args.dest:
+            path = os.path.abspath(os.path.expanduser(self.args.dest))
+            if os.path.isdir(path):
+                path = os.path.join(path, self.filename)
+        else:
+            path = os.path.abspath(self.filename)
+
+        if os.path.exists(path) and not self.args.force:
+            message = _("File {path} already exists! Do you want to overwrite?").format(
+                path=path
+            )
+            await self.host.confirm_or_quit(message, _("file request cancelled"))
+
+        self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
+        extra = {}
+        if self.args.path:
+            extra["path"] = self.args.path
+        if self.args.namespace:
+            extra["namespace"] = self.args.namespace
+        try:
+            progress_id = await self.host.bridge.file_jingle_request(
+                self.full_dest_jid,
+                path,
+                self.args.name,
+                self.args.hash,
+                self.args.hash_algo if self.args.hash else "",
+                extra,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't request file: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.set_progress_id(progress_id)
+
+
+class Receive(base.CommandAnswering):
+    def __init__(self, host):
+        super(Receive, self).__init__(
+            host,
+            "receive",
+            use_progress=True,
+            use_verbose=True,
+            help=_("wait for a file to be sent by a contact"),
+        )
+        self._overwrite_refused = False  # True when one overwrite as already been refused
+        self.action_callbacks = {
+            C.META_TYPE_FILE: self.on_file_action,
+            C.META_TYPE_OVERWRITE: self.on_overwrite_action,
+            C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action,
+        }
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jids",
+            nargs="*",
+            help=_("jids accepted (accept everything if none is specified)"),
+        )
+        self.parser.add_argument(
+            "-m",
+            "--multiple",
+            action="store_true",
+            help=_("accept multiple files (you'll have to stop manually)"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_(
+                "force overwritting of existing files (/!\\ name is choosed by sender)"
+            ),
+        )
+        self.parser.add_argument(
+            "--path",
+            default=".",
+            metavar="DIR",
+            help=_("destination path (default: working directory)"),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File copy started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File received successfully"), 2)
+        if metadata.get("hash_verified", False):
+            try:
+                self.disp(
+                    _("hash checked: {metadata['hash_algo']}:{metadata['hash']}"), 1
+                )
+            except KeyError:
+                self.disp(_("hash is checked but hash value is missing", 1), error=True)
+        else:
+            self.disp(_("hash can't be verified"), 1)
+
+    async def on_progress_error(self, e):
+        self.disp(_("Error while receiving file: {e}").format(e=e), error=True)
+
+    def get_xmlui_id(self, action_data):
+        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
+        #        should be available in the futur
+        # TODO: XMLUI module
+        try:
+            xml_ui = action_data["xmlui"]
+        except KeyError:
+            self.disp(_("Action has no XMLUI"), 1)
+        else:
+            ui = ET.fromstring(xml_ui.encode("utf-8"))
+            xmlui_id = ui.get("submit")
+            if not xmlui_id:
+                self.disp(_("Invalid XMLUI received"), error=True)
+            return xmlui_id
+
+    async def on_file_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            return self.host.quit_from_signal(1)
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except KeyError:
+            self.disp(_("Ignoring action without from_jid data"), 1)
+            return
+        try:
+            progress_id = action_data["progress_id"]
+        except KeyError:
+            self.disp(_("ignoring action without progress id"), 1)
+            return
+
+        if not self.bare_jids or from_jid.bare in self.bare_jids:
+            if self._overwrite_refused:
+                self.disp(_("File refused because overwrite is needed"), error=True)
+                await self.host.bridge.action_launch(
+                    xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}),
+                    profile_key=profile
+                )
+                return self.host.quit_from_signal(2)
+            await self.set_progress_id(progress_id)
+            xmlui_data = {"path": self.path}
+            await self.host.bridge.action_launch(
+                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+            )
+
+    async def on_overwrite_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            return self.host.quit_from_signal(1)
+        try:
+            progress_id = action_data["progress_id"]
+        except KeyError:
+            self.disp(_("ignoring action without progress id"), 1)
+            return
+        self.disp(_("Overwriting needed"), 1)
+
+        if progress_id == self.progress_id:
+            if self.args.force:
+                self.disp(_("Overwrite accepted"), 2)
+            else:
+                self.disp(_("Refused to overwrite"), 2)
+                self._overwrite_refused = True
+
+            xmlui_data = {"answer": C.bool_const(self.args.force)}
+            await self.host.bridge.action_launch(
+                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+            )
+
+    async def on_not_in_roster_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            return self.host.quit_from_signal(1)
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except ValueError:
+            self.disp(
+                _('invalid "from_jid" value received, ignoring: {value}').format(
+                    value=from_jid
+                ),
+                error=True,
+            )
+            return
+        except KeyError:
+            self.disp(_('ignoring action without "from_jid" value'), error=True)
+            return
+        self.disp(_("Confirmation needed for request from an entity not in roster"), 1)
+
+        if from_jid.bare in self.bare_jids:
+            # if the sender is expected, we can confirm the session
+            confirmed = True
+            self.disp(_("Sender confirmed because she or he is explicitly expected"), 1)
+        else:
+            xmlui = xmlui_manager.create(self.host, action_data["xmlui"])
+            confirmed = await self.host.confirm(xmlui.dlg.message)
+
+        xmlui_data = {"answer": C.bool_const(confirmed)}
+        await self.host.bridge.action_launch(
+            xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+        )
+        if not confirmed and not self.args.multiple:
+            self.disp(_("Session refused for {from_jid}").format(from_jid=from_jid))
+            self.host.quit_from_signal(0)
+
+    async def start(self):
+        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
+        self.path = os.path.abspath(self.args.path)
+        if not os.path.isdir(self.path):
+            self.disp(_("Given path is not a directory !", error=True))
+            self.host.quit(C.EXIT_BAD_ARG)
+        if self.args.multiple:
+            self.host.quit_on_progress_end = False
+        self.disp(_("waiting for incoming file request"), 2)
+        await self.start_answering()
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        super(Get, self).__init__(
+            host,
+            "get",
+            use_progress=True,
+            use_verbose=True,
+            help=_("download a file from URI"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-o",
+            "--dest-file",
+            type=str,
+            default="",
+            help=_("destination file (DEFAULT: filename from URL)"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("overwrite existing file without confirmation"),
+        )
+        self.parser.add_argument(
+            "attachment", type=str,
+            help=_("URI of the file to retrieve or JSON of the whole attachment")
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File download started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File downloaded successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        self.disp(_("Error while downloading file: {}").format(error_msg), error=True)
+
+    async def got_id(self, data):
+        """Called when a progress id has been received"""
+        try:
+            await self.set_progress_id(data["progress"])
+        except KeyError:
+            if "xmlui" in data:
+                ui = xmlui_manager.create(self.host, data["xmlui"])
+                await ui.show()
+            else:
+                self.disp(_("Can't download file"), error=True)
+            self.host.quit(C.EXIT_ERROR)
+
+    async def start(self):
+        try:
+            attachment = json.loads(self.args.attachment)
+        except json.JSONDecodeError:
+            attachment = {"uri": self.args.attachment}
+        dest_file = self.args.dest_file
+        if not dest_file:
+            try:
+                dest_file = attachment["name"].replace("/", "-").strip()
+            except KeyError:
+                try:
+                    dest_file = Path(urlparse(attachment["uri"]).path).name.strip()
+                except KeyError:
+                    pass
+            if not dest_file:
+                dest_file = "downloaded_file"
+
+        dest_file = Path(dest_file).expanduser().resolve()
+        if dest_file.exists() and not self.args.force:
+            message = _("File {path} already exists! Do you want to overwrite?").format(
+                path=dest_file
+            )
+            await self.host.confirm_or_quit(message, _("file download cancelled"))
+
+        options = {}
+
+        try:
+            download_data_s = await self.host.bridge.file_download(
+                data_format.serialise(attachment),
+                str(dest_file),
+                data_format.serialise(options),
+                self.profile,
+            )
+            download_data = data_format.deserialise(download_data_s)
+        except Exception as e:
+            self.disp(f"error while trying to download a file: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.got_id(download_data)
+
+
+class Upload(base.CommandBase):
+    def __init__(self, host):
+        super(Upload, self).__init__(
+            host, "upload", use_progress=True, use_verbose=True, help=_("upload a file")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("encrypt file using AES-GCM"),
+        )
+        self.parser.add_argument("file", type=str, help=_("file to upload"))
+        self.parser.add_argument(
+            "jid",
+            nargs="?",
+            help=_("jid of upload component (nothing to autodetect)"),
+        )
+        self.parser.add_argument(
+            "--ignore-tls-errors",
+            action="store_true",
+            help=_(r"ignore invalide TLS certificate (/!\ Dangerous /!\)"),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File upload started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File uploaded successfully"), 2)
+        try:
+            url = metadata["url"]
+        except KeyError:
+            self.disp("download URL not found in metadata")
+        else:
+            self.disp(_("URL to retrieve the file:"), 1)
+            # XXX: url is displayed alone on a line to make parsing easier
+            self.disp(url)
+
+    async def on_progress_error(self, error_msg):
+        self.disp(_("Error while uploading file: {}").format(error_msg), error=True)
+
+    async def got_id(self, data, file_):
+        """Called when a progress id has been received
+
+        @param pid(unicode): progress id
+        @param file_(str): file path
+        """
+        try:
+            await self.set_progress_id(data["progress"])
+        except KeyError:
+            if "xmlui" in data:
+                ui = xmlui_manager.create(self.host, data["xmlui"])
+                await ui.show()
+            else:
+                self.disp(_("Can't upload file"), error=True)
+            self.host.quit(C.EXIT_ERROR)
+
+    async def start(self):
+        file_ = self.args.file
+        if not os.path.exists(file_):
+            self.disp(
+                _("file {file_} doesn't exist !").format(file_=repr(file_)), error=True
+            )
+            self.host.quit(C.EXIT_BAD_ARG)
+        if os.path.isdir(file_):
+            self.disp(_("{file_} is a dir! Can't upload a dir").format(file_=repr(file_)))
+            self.host.quit(C.EXIT_BAD_ARG)
+
+        if self.args.jid is None:
+            self.full_dest_jid = ""
+        else:
+            self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
+
+        options = {}
+        if self.args.ignore_tls_errors:
+            options["ignore_tls_errors"] = True
+        if self.args.encrypt:
+            options["encryption"] = C.ENC_AES_GCM
+
+        path = os.path.abspath(file_)
+        try:
+            upload_data = await self.host.bridge.file_upload(
+                path,
+                "",
+                self.full_dest_jid,
+                data_format.serialise(options),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"error while trying to upload a file: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.got_id(upload_data, file_)
+
+
+class ShareAffiliationsSet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareAffiliationsSet, self).__init__(
+            host,
+            "set",
+            use_output=C.OUTPUT_DICT,
+            help=_("set affiliations for a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "-a",
+            "--affiliation",
+            dest="affiliations",
+            metavar=("JID", "AFFILIATION"),
+            required=True,
+            action="append",
+            nargs=2,
+            help=_("entity/affiliation couple(s)"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of file sharing entity"),
+        )
+
+    async def start(self):
+        affiliations = dict(self.args.affiliations)
+        try:
+            affiliations = await self.host.bridge.fis_affiliations_set(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                affiliations,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class ShareAffiliationsGet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareAffiliationsGet, self).__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            help=_("retrieve affiliations of a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of sharing entity"),
+        )
+
+    async def start(self):
+        try:
+            affiliations = await self.host.bridge.fis_affiliations_get(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(affiliations)
+            self.host.quit()
+
+
+class ShareAffiliations(base.CommandBase):
+    subcommands = (ShareAffiliationsGet, ShareAffiliationsSet)
+
+    def __init__(self, host):
+        super(ShareAffiliations, self).__init__(
+            host, "affiliations", use_profile=False, help=_("affiliations management")
+        )
+
+
+class ShareConfigurationSet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareConfigurationSet, self).__init__(
+            host,
+            "set",
+            use_output=C.OUTPUT_DICT,
+            help=_("set configuration for a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            required=True,
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set (required)"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of file sharing entity"),
+        )
+
+    async def start(self):
+        configuration = dict(self.args.fields)
+        try:
+            configuration = await self.host.bridge.fis_configuration_set(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                configuration,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set configuration: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class ShareConfigurationGet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareConfigurationGet, self).__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            help=_("retrieve configuration of a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of sharing entity"),
+        )
+
+    async def start(self):
+        try:
+            configuration = await self.host.bridge.fis_configuration_get(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get configuration: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(configuration)
+            self.host.quit()
+
+
+class ShareConfiguration(base.CommandBase):
+    subcommands = (ShareConfigurationGet, ShareConfigurationSet)
+
+    def __init__(self, host):
+        super(ShareConfiguration, self).__init__(
+            host,
+            "configuration",
+            use_profile=False,
+            help=_("file sharing node configuration"),
+        )
+
+
+class ShareList(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(ShareList, self).__init__(
+            host,
+            "list",
+            use_output=C.OUTPUT_LIST_DICT,
+            extra_outputs=extra_outputs,
+            help=_("retrieve files shared by an entity"),
+            use_verbose=True,
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-d",
+            "--path",
+            default="",
+            help=_("path to the directory containing the files"),
+        )
+        self.parser.add_argument(
+            "jid",
+            nargs="?",
+            default="",
+            help=_("jid of sharing entity (nothing to check our own jid)"),
+        )
+
+    def _name_filter(self, name, row):
+        if row.type == C.FILE_TYPE_DIRECTORY:
+            return A.color(C.A_DIRECTORY, name)
+        elif row.type == C.FILE_TYPE_FILE:
+            return A.color(C.A_FILE, name)
+        else:
+            self.disp(_("unknown file type: {type}").format(type=row.type), error=True)
+            return name
+
+    def _size_filter(self, size, row):
+        if not size:
+            return ""
+        return A.color(A.BOLD, utils.get_human_size(size))
+
+    def default_output(self, files_data):
+        """display files a way similar to ls"""
+        files_data.sort(key=lambda d: d["name"].lower())
+        show_header = False
+        if self.verbosity == 0:
+            keys = headers = ("name", "type")
+        elif self.verbosity == 1:
+            keys = headers = ("name", "type", "size")
+        elif self.verbosity > 1:
+            show_header = True
+            keys = ("name", "type", "size", "file_hash")
+            headers = ("name", "type", "size", "hash")
+        table = common.Table.from_list_dict(
+            self.host,
+            files_data,
+            keys=keys,
+            headers=headers,
+            filters={"name": self._name_filter, "size": self._size_filter},
+            defaults={"size": "", "file_hash": ""},
+        )
+        table.display_blank(show_header=show_header, hide_cols=["type"])
+
+    async def start(self):
+        try:
+            files_data = await self.host.bridge.fis_list(
+                self.args.jid,
+                self.args.path,
+                {},
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve shared files: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        await self.output(files_data)
+        self.host.quit()
+
+
+class SharePath(base.CommandBase):
+    def __init__(self, host):
+        super(SharePath, self).__init__(
+            host, "path", help=_("share a file or directory"), use_verbose=True
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=_("virtual name to use (default: use directory/file name)"),
+        )
+        perm_group = self.parser.add_mutually_exclusive_group()
+        perm_group.add_argument(
+            "-j",
+            "--jid",
+            metavar="JID",
+            action="append",
+            dest="jids",
+            default=[],
+            help=_("jid of contacts allowed to retrieve the files"),
+        )
+        perm_group.add_argument(
+            "--public",
+            action="store_true",
+            help=_(
+                r"share publicly the file(s) (/!\ *everybody* will be able to access "
+                r"them)"
+            ),
+        )
+        self.parser.add_argument(
+            "path",
+            help=_("path to a file or directory to share"),
+        )
+
+    async def start(self):
+        self.path = os.path.abspath(self.args.path)
+        if self.args.public:
+            access = {"read": {"type": "public"}}
+        else:
+            jids = self.args.jids
+            if jids:
+                access = {"read": {"type": "whitelist", "jids": jids}}
+            else:
+                access = {}
+        try:
+            name = await self.host.bridge.fis_share_path(
+                self.args.name,
+                self.path,
+                json.dumps(access, ensure_ascii=False),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't share path: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _('{path} shared under the name "{name}"').format(
+                    path=self.path, name=name
+                )
+            )
+            self.host.quit()
+
+
+class ShareInvite(base.CommandBase):
+    def __init__(self, host):
+        super(ShareInvite, self).__init__(
+            host, "invite", help=_("send invitation for a shared repository")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=_("name of the repository"),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            choices=["files", "photos"],
+            default="files",
+            help=_("type of the repository"),
+        )
+        self.parser.add_argument(
+            "-T",
+            "--thumbnail",
+            help=_("https URL of a image to use as thumbnail"),
+        )
+        self.parser.add_argument(
+            "service",
+            help=_("jid of the file sharing service hosting the repository"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the person to invite"),
+        )
+
+    async def start(self):
+        self.path = os.path.normpath(self.args.path) if self.args.path else ""
+        extra = {}
+        if self.args.thumbnail is not None:
+            if not self.args.thumbnail.startswith("http"):
+                self.parser.error(_("only http(s) links are allowed with --thumbnail"))
+            else:
+                extra["thumb_url"] = self.args.thumbnail
+        try:
+            await self.host.bridge.fis_invite(
+                self.args.jid,
+                self.args.service,
+                self.args.type,
+                self.args.namespace,
+                self.path,
+                self.args.name,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("invitation sent to {jid}").format(jid=self.args.jid))
+            self.host.quit()
+
+
+class Share(base.CommandBase):
+    subcommands = (
+        ShareList,
+        SharePath,
+        ShareInvite,
+        ShareAffiliations,
+        ShareConfiguration,
+    )
+
+    def __init__(self, host):
+        super(Share, self).__init__(
+            host, "share", use_profile=False, help=_("files sharing management")
+        )
+
+
+class File(base.CommandBase):
+    subcommands = (Send, Request, Receive, Get, Upload, Share)
+
+    def __init__(self, host):
+        super(File, self).__init__(
+            host, "file", use_profile=False, help=_("files sending/receiving/management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_forums.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.cli import common
+from libervia.backend.tools.common.ansi import ANSI as A
+import codecs
+import json
+
+__commands__ = ["Forums"]
+
+FORUMS_TMP_DIR = "forums"
+
+
+class Edit(base.CommandBase, common.BaseEdit):
+    use_items = False
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_pubsub=True,
+            use_draft=True,
+            use_verbose=True,
+            help=_("edit forums"),
+        )
+        common.BaseEdit.__init__(self, self.host, FORUMS_TMP_DIR)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            default="",
+            help=_("forum key (DEFAULT: default forums)"),
+        )
+
+    def get_tmp_suff(self):
+        """return suffix used for content file"""
+        return "json"
+
+    async def publish(self, forums_raw):
+        try:
+            await self.host.bridge.forums_set(
+                forums_raw,
+                self.args.service,
+                self.args.node,
+                self.args.key,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set forums: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("forums have been edited"), 1)
+            self.host.quit()
+
+    async def start(self):
+        try:
+            forums_json = await self.host.bridge.forums_get(
+                self.args.service,
+                self.args.node,
+                self.args.key,
+                self.profile,
+            )
+        except Exception as e:
+            if e.classname == "NotFound":
+                forums_json = ""
+            else:
+                self.disp(f"can't get node configuration: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        content_file_obj, content_file_path = self.get_tmp_file()
+        forums_json = forums_json.strip()
+        if forums_json:
+            # we loads and dumps to have pretty printed json
+            forums = json.loads(forums_json)
+            # cf. https://stackoverflow.com/a/18337754
+            f = codecs.getwriter("utf-8")(content_file_obj)
+            json.dump(forums, f, ensure_ascii=False, indent=4)
+            content_file_obj.seek(0)
+        await self.run_editor("forums_editor_args", content_file_path, content_file_obj)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_COMPLEX,
+            extra_outputs=extra_outputs,
+            use_pubsub=True,
+            use_verbose=True,
+            help=_("get forums structure"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            default="",
+            help=_("forum key (DEFAULT: default forums)"),
+        )
+
+    def default_output(self, forums, level=0):
+        for forum in forums:
+            keys = list(forum.keys())
+            keys.sort()
+            try:
+                keys.remove("title")
+            except ValueError:
+                pass
+            else:
+                keys.insert(0, "title")
+            try:
+                keys.remove("sub-forums")
+            except ValueError:
+                pass
+            else:
+                keys.append("sub-forums")
+
+            for key in keys:
+                value = forum[key]
+                if key == "sub-forums":
+                    self.default_output(value, level + 1)
+                else:
+                    if self.host.verbosity < 1 and key != "title":
+                        continue
+                    head_color = C.A_LEVEL_COLORS[level % len(C.A_LEVEL_COLORS)]
+                    self.disp(
+                        A.color(level * 4 * " ", head_color, key, A.RESET, ": ", value)
+                    )
+
+    async def start(self):
+        try:
+            forums_raw = await self.host.bridge.forums_get(
+                self.args.service,
+                self.args.node,
+                self.args.key,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get forums: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not forums_raw:
+                self.disp(_("no schema found"), 1)
+                self.host.quit(1)
+            forums = json.loads(forums_raw)
+            await self.output(forums)
+            self.host.quit()
+
+
+class Forums(base.CommandBase):
+    subcommands = (Get, Edit)
+
+    def __init__(self, host):
+        super(Forums, self).__init__(
+            host, "forums", use_profile=False, help=_("Forums structure edition")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_identity.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common import data_format
+
+__commands__ = ["Identity"]
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_verbose=True,
+            help=_("get identity data"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--no-cache", action="store_true", help=_("do no use cached values")
+        )
+        self.parser.add_argument(
+            "jid", help=_("entity to check")
+        )
+
+    async def start(self):
+        jid_ = (await self.host.check_jids([self.args.jid]))[0]
+        try:
+            data = await self.host.bridge.identity_get(
+                jid_,
+                [],
+                not self.args.no_cache,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't get identity data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            data = data_format.deserialise(data)
+            await self.output(data)
+            self.host.quit()
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        super(Set, self).__init__(host, "set", help=_("update identity data"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n",
+            "--nickname",
+            action="append",
+            metavar="NICKNAME",
+            dest="nicknames",
+            help=_("nicknames of the entity"),
+        )
+        self.parser.add_argument(
+            "-d",
+            "--description",
+            help=_("description of the entity"),
+        )
+
+    async def start(self):
+        id_data = {}
+        for field in ("nicknames", "description"):
+            value = getattr(self.args, field)
+            if value is not None:
+                id_data[field] = value
+        if not id_data:
+            self.parser.error("At least one metadata must be set")
+        try:
+            self.host.bridge.identity_set(
+                data_format.serialise(id_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set identity data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Identity(base.CommandBase):
+    subcommands = (Get, Set)
+
+    def __init__(self, host):
+        super(Identity, self).__init__(
+            host, "identity", use_profile=False, help=_("identity management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_info.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,386 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from pprint import pformat
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format, date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.cli import common
+from libervia.cli.constants import Const as C
+
+from . import base
+
+__commands__ = ["Info"]
+
+
+class Disco(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(Disco, self).__init__(
+            host,
+            "disco",
+            use_output="complex",
+            extra_outputs=extra_outputs,
+            help=_("service discovery"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", help=_("entity to discover"))
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            type=str,
+            choices=("infos", "items", "both", "external", "all"),
+            default="all",
+            help=_("type of data to discover"),
+        )
+        self.parser.add_argument("-n", "--node", default="", help=_("node to use"))
+        self.parser.add_argument(
+            "-C",
+            "--no-cache",
+            dest="use_cache",
+            action="store_false",
+            help=_("ignore cache"),
+        )
+
+    def default_output(self, data):
+        features = data.get("features", [])
+        identities = data.get("identities", [])
+        extensions = data.get("extensions", {})
+        items = data.get("items", [])
+        external = data.get("external", [])
+
+        identities_table = common.Table(
+            self.host,
+            identities,
+            headers=(_("category"), _("type"), _("name")),
+            use_buffer=True,
+        )
+
+        extensions_tpl = []
+        extensions_types = list(extensions.keys())
+        extensions_types.sort()
+        for type_ in extensions_types:
+            fields = []
+            for field in extensions[type_]:
+                field_lines = []
+                data, values = field
+                data_keys = list(data.keys())
+                data_keys.sort()
+                for key in data_keys:
+                    field_lines.append(
+                        A.color("\t", C.A_SUBHEADER, key, A.RESET, ": ", data[key])
+                    )
+                if len(values) == 1:
+                    field_lines.append(
+                        A.color(
+                            "\t",
+                            C.A_SUBHEADER,
+                            "value",
+                            A.RESET,
+                            ": ",
+                            values[0] or (A.BOLD + "UNSET"),
+                        )
+                    )
+                elif len(values) > 1:
+                    field_lines.append(
+                        A.color("\t", C.A_SUBHEADER, "values", A.RESET, ": ")
+                    )
+
+                    for value in values:
+                        field_lines.append(A.color("\t  - ", A.BOLD, value))
+                fields.append("\n".join(field_lines))
+            extensions_tpl.append(
+                "{type_}\n{fields}".format(type_=type_, fields="\n\n".join(fields))
+            )
+
+        items_table = common.Table(
+            self.host, items, headers=(_("entity"), _("node"), _("name")), use_buffer=True
+        )
+
+        template = []
+        fmt_kwargs = {}
+        if features:
+            template.append(A.color(C.A_HEADER, _("Features")) + "\n\n{features}")
+        if identities:
+            template.append(A.color(C.A_HEADER, _("Identities")) + "\n\n{identities}")
+        if extensions:
+            template.append(A.color(C.A_HEADER, _("Extensions")) + "\n\n{extensions}")
+        if items:
+            template.append(A.color(C.A_HEADER, _("Items")) + "\n\n{items}")
+        if external:
+            fmt_lines = []
+            for e in external:
+                data = {k: e[k] for k in sorted(e)}
+                host = data.pop("host")
+                type_ = data.pop("type")
+                fmt_lines.append(A.color(
+                    "\t",
+                    C.A_SUBHEADER,
+                    host,
+                    " ",
+                    A.RESET,
+                    "[",
+                    C.A_LEVEL_COLORS[1],
+                    type_,
+                    A.RESET,
+                    "]",
+                ))
+                extended = data.pop("extended", None)
+                for key, value in data.items():
+                    fmt_lines.append(A.color(
+                        "\t\t",
+                        C.A_LEVEL_COLORS[2],
+                        f"{key}: ",
+                        C.A_LEVEL_COLORS[3],
+                        str(value)
+                    ))
+                if extended:
+                    fmt_lines.append(A.color(
+                        "\t\t",
+                        C.A_HEADER,
+                        "extended",
+                    ))
+                    nb_extended = len(extended)
+                    for idx, form_data in enumerate(extended):
+                        namespace = form_data.get("namespace")
+                        if namespace:
+                            fmt_lines.append(A.color(
+                                "\t\t",
+                                C.A_LEVEL_COLORS[2],
+                                "namespace: ",
+                                C.A_LEVEL_COLORS[3],
+                                A.BOLD,
+                                namespace
+                            ))
+                        for field_data in form_data["fields"]:
+                            name = field_data.get("name")
+                            if not name:
+                                continue
+                            field_type = field_data.get("type")
+                            if "multi" in field_type:
+                                value = ", ".join(field_data.get("values") or [])
+                            else:
+                                value = field_data.get("value")
+                                if value is None:
+                                    continue
+                                if field_type == "boolean":
+                                    value = C.bool(value)
+                            fmt_lines.append(A.color(
+                                "\t\t",
+                                C.A_LEVEL_COLORS[2],
+                                f"{name}: ",
+                                C.A_LEVEL_COLORS[3],
+                                A.BOLD,
+                                str(value)
+                            ))
+                        if nb_extended>1 and idx < nb_extended-1:
+                            fmt_lines.append("\n")
+
+                fmt_lines.append("\n")
+
+            template.append(
+                A.color(C.A_HEADER, _("External")) + "\n\n{external_formatted}"
+            )
+            fmt_kwargs["external_formatted"] = "\n".join(fmt_lines)
+
+        print(
+            "\n\n".join(template).format(
+                features="\n".join(features),
+                identities=identities_table.display().string,
+                extensions="\n".join(extensions_tpl),
+                items=items_table.display().string,
+                **fmt_kwargs,
+            )
+        )
+
+    async def start(self):
+        infos_requested = self.args.type in ("infos", "both", "all")
+        items_requested = self.args.type in ("items", "both", "all")
+        exter_requested = self.args.type in ("external", "all")
+        if self.args.node:
+            if self.args.type == "external":
+                self.parser.error(
+                    '--node can\'t be used with discovery of external services '
+                    '(--type="external")'
+                )
+            else:
+                exter_requested = False
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        data = {}
+
+        # infos
+        if infos_requested:
+            try:
+                infos = await self.host.bridge.disco_infos(
+                    jid,
+                    node=self.args.node,
+                    use_cache=self.args.use_cache,
+                    profile_key=self.host.profile,
+                )
+            except Exception as e:
+                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            else:
+                features, identities, extensions = infos
+                features.sort()
+                identities.sort(key=lambda identity: identity[2])
+                data.update(
+                    {"features": features, "identities": identities, "extensions": extensions}
+                )
+
+        # items
+        if items_requested:
+            try:
+                items = await self.host.bridge.disco_items(
+                    jid,
+                    node=self.args.node,
+                    use_cache=self.args.use_cache,
+                    profile_key=self.host.profile,
+                )
+            except Exception as e:
+                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                items.sort(key=lambda item: item[2])
+                data["items"] = items
+
+        # external
+        if exter_requested:
+            try:
+                ext_services_s = await self.host.bridge.external_disco_get(
+                    jid,
+                    self.host.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("error while doing external service discovery: {e}").format(e=e),
+                    error=True
+                )
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                data["external"] = data_format.deserialise(
+                    ext_services_s, type_check=list
+                )
+
+        # output
+        await self.output(data)
+        self.host.quit()
+
+
+class Version(base.CommandBase):
+    def __init__(self, host):
+        super(Version, self).__init__(host, "version", help=_("software version"))
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", type=str, help=_("Entity to request"))
+
+    async def start(self):
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        try:
+            data = await self.host.bridge.software_version_get(jid, self.host.profile)
+        except Exception as e:
+            self.disp(_("error while trying to get version: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            infos = []
+            name, version, os = data
+            if name:
+                infos.append(_("Software name: {name}").format(name=name))
+            if version:
+                infos.append(_("Software version: {version}").format(version=version))
+            if os:
+                infos.append(_("Operating System: {os}").format(os=os))
+
+            print("\n".join(infos))
+            self.host.quit()
+
+
+class Session(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(Session, self).__init__(
+            host,
+            "session",
+            use_output="dict",
+            extra_outputs=extra_outputs,
+            help=_("running session"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def default_output(self, data):
+        started = data["started"]
+        data["started"] = "{short} (UTC, {relative})".format(
+            short=date_utils.date_fmt(started),
+            relative=date_utils.date_fmt(started, "relative"),
+        )
+        await self.host.output(C.OUTPUT_DICT, "simple", {}, data)
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.session_infos_get(self.host.profile)
+        except Exception as e:
+            self.disp(_("Error getting session infos: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(data)
+            self.host.quit()
+
+
+class Devices(base.CommandBase):
+    def __init__(self, host):
+        super(Devices, self).__init__(
+            host, "devices", use_output=C.OUTPUT_LIST_DICT, help=_("devices of an entity")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid", type=str, nargs="?", default="", help=_("Entity to request")
+        )
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.devices_infos_get(
+                self.args.jid, self.host.profile
+            )
+        except Exception as e:
+            self.disp(_("Error getting devices infos: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            data = data_format.deserialise(data, type_check=list)
+            await self.output(data)
+            self.host.quit()
+
+
+class Info(base.CommandBase):
+    subcommands = (Disco, Version, Session, Devices)
+
+    def __init__(self, host):
+        super(Info, self).__init__(
+            host,
+            "info",
+            use_profile=False,
+            help=_("Get various pieces of information on entities"),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_input.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import subprocess
+import argparse
+import sys
+import shlex
+import asyncio
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+
+__commands__ = ["Input"]
+OPT_STDIN = "stdin"
+OPT_SHORT = "short"
+OPT_LONG = "long"
+OPT_POS = "positional"
+OPT_IGNORE = "ignore"
+OPT_TYPES = (OPT_STDIN, OPT_SHORT, OPT_LONG, OPT_POS, OPT_IGNORE)
+OPT_EMPTY_SKIP = "skip"
+OPT_EMPTY_IGNORE = "ignore"
+OPT_EMPTY_CHOICES = (OPT_EMPTY_SKIP, OPT_EMPTY_IGNORE)
+
+
+class InputCommon(base.CommandBase):
+    def __init__(self, host, name, help):
+        base.CommandBase.__init__(
+            self, host, name, use_verbose=True, use_profile=False, help=help
+        )
+        self.idx = 0
+        self.reset()
+
+    def reset(self):
+        self.args_idx = 0
+        self._stdin = []
+        self._opts = []
+        self._pos = []
+        self._values_ori = []
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--encoding", default="utf-8", help=_("encoding of the input data")
+        )
+        self.parser.add_argument(
+            "-i",
+            "--stdin",
+            action="append_const",
+            const=(OPT_STDIN, None),
+            dest="arguments",
+            help=_("standard input"),
+        )
+        self.parser.add_argument(
+            "-s",
+            "--short",
+            type=self.opt(OPT_SHORT),
+            action="append",
+            dest="arguments",
+            help=_("short option"),
+        )
+        self.parser.add_argument(
+            "-l",
+            "--long",
+            type=self.opt(OPT_LONG),
+            action="append",
+            dest="arguments",
+            help=_("long option"),
+        )
+        self.parser.add_argument(
+            "-p",
+            "--positional",
+            type=self.opt(OPT_POS),
+            action="append",
+            dest="arguments",
+            help=_("positional argument"),
+        )
+        self.parser.add_argument(
+            "-x",
+            "--ignore",
+            action="append_const",
+            const=(OPT_IGNORE, None),
+            dest="arguments",
+            help=_("ignore value"),
+        )
+        self.parser.add_argument(
+            "-D",
+            "--debug",
+            action="store_true",
+            help=_("don't actually run commands but echo what would be launched"),
+        )
+        self.parser.add_argument(
+            "--log", type=argparse.FileType("w"), help=_("log stdout to FILE")
+        )
+        self.parser.add_argument(
+            "--log-err", type=argparse.FileType("w"), help=_("log stderr to FILE")
+        )
+        self.parser.add_argument("command", nargs=argparse.REMAINDER)
+
+    def opt(self, type_):
+        return lambda s: (type_, s)
+
+    def add_value(self, value):
+        """add a parsed value according to arguments sequence"""
+        self._values_ori.append(value)
+        arguments = self.args.arguments
+        try:
+            arg_type, arg_name = arguments[self.args_idx]
+        except IndexError:
+            self.disp(
+                _("arguments in input data and in arguments sequence don't match"),
+                error=True,
+            )
+            self.host.quit(C.EXIT_DATA_ERROR)
+        self.args_idx += 1
+        while self.args_idx < len(arguments):
+            next_arg = arguments[self.args_idx]
+            if next_arg[0] not in OPT_TYPES:
+                # value will not be used if False or None, so we skip filter
+                if value not in (False, None):
+                    # we have a filter
+                    filter_type, filter_arg = arguments[self.args_idx]
+                    value = self.filter(filter_type, filter_arg, value)
+            else:
+                break
+            self.args_idx += 1
+
+        if value is None:
+            # we ignore this argument
+            return
+
+        if value is False:
+            # we skip the whole row
+            if self.args.debug:
+                self.disp(
+                    A.color(
+                        C.A_SUBHEADER,
+                        _("values: "),
+                        A.RESET,
+                        ", ".join(self._values_ori),
+                    ),
+                    2,
+                )
+                self.disp(A.color(A.BOLD, _("**SKIPPING**\n")))
+            self.reset()
+            self.idx += 1
+            raise exceptions.CancelError
+
+        if not isinstance(value, list):
+            value = [value]
+
+        for v in value:
+            if arg_type == OPT_STDIN:
+                self._stdin.append(v)
+            elif arg_type == OPT_SHORT:
+                self._opts.append("-{}".format(arg_name))
+                self._opts.append(v)
+            elif arg_type == OPT_LONG:
+                self._opts.append("--{}".format(arg_name))
+                self._opts.append(v)
+            elif arg_type == OPT_POS:
+                self._pos.append(v)
+            elif arg_type == OPT_IGNORE:
+                pass
+            else:
+                self.parser.error(
+                    _(
+                        "Invalid argument, an option type is expected, got {type_}:{name}"
+                    ).format(type_=arg_type, name=arg_name)
+                )
+
+    async def runCommand(self):
+        """run requested command with parsed arguments"""
+        if self.args_idx != len(self.args.arguments):
+            self.disp(
+                _("arguments in input data and in arguments sequence don't match"),
+                error=True,
+            )
+            self.host.quit(C.EXIT_DATA_ERROR)
+        end = '\n' if self.args.debug else ' '
+        self.disp(
+            A.color(C.A_HEADER, _("command {idx}").format(idx=self.idx)),
+            end = end,
+        )
+        stdin = "".join(self._stdin)
+        if self.args.debug:
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("values: "),
+                    A.RESET,
+                    ", ".join([shlex.quote(a) for a in self._values_ori])
+                ),
+                2,
+            )
+
+            if stdin:
+                self.disp(A.color(C.A_SUBHEADER, "--- STDIN ---"))
+                self.disp(stdin)
+                self.disp(A.color(C.A_SUBHEADER, "-------------"))
+
+            self.disp(
+                "{indent}{prog} {static} {options} {positionals}".format(
+                    indent=4 * " ",
+                    prog=sys.argv[0],
+                    static=" ".join(self.args.command),
+                    options=" ".join(shlex.quote(o) for o in self._opts),
+                    positionals=" ".join(shlex.quote(p) for p in self._pos),
+                )
+            )
+            self.disp("\n")
+        else:
+            self.disp(" (" + ", ".join(self._values_ori) + ")", 2, end=' ')
+            args = [sys.argv[0]] + self.args.command + self._opts + self._pos
+            p = await asyncio.create_subprocess_exec(
+                *args,
+                stdin=subprocess.PIPE,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+            )
+            stdout, stderr = await p.communicate(stdin.encode('utf-8'))
+            log = self.args.log
+            log_err = self.args.log_err
+            log_tpl = "{command}\n{buff}\n\n"
+            if log:
+                log.write(log_tpl.format(
+                    command=" ".join(shlex.quote(a) for a in args),
+                    buff=stdout.decode('utf-8', 'replace')))
+            if log_err:
+                log_err.write(log_tpl.format(
+                    command=" ".join(shlex.quote(a) for a in args),
+                    buff=stderr.decode('utf-8', 'replace')))
+            ret = p.returncode
+            if ret == 0:
+                self.disp(A.color(C.A_SUCCESS, _("OK")))
+            else:
+                self.disp(A.color(C.A_FAILURE, _("FAILED")))
+
+        self.reset()
+        self.idx += 1
+
+    def filter(self, filter_type, filter_arg, value):
+        """change input value
+
+        @param filter_type(unicode): name of the filter
+        @param filter_arg(unicode, None): argument of the filter
+        @param value(unicode): value to filter
+        @return (unicode, False, None): modified value
+            False to skip the whole row
+            None to ignore this argument (but continue row with other ones)
+        """
+        raise NotImplementedError
+
+
+class Csv(InputCommon):
+    def __init__(self, host):
+        super(Csv, self).__init__(host, "csv", _("comma-separated values"))
+
+    def add_parser_options(self):
+        InputCommon.add_parser_options(self)
+        self.parser.add_argument(
+            "-r",
+            "--row",
+            type=int,
+            default=0,
+            help=_("starting row (previous ones will be ignored)"),
+        )
+        self.parser.add_argument(
+            "-S",
+            "--split",
+            action="append_const",
+            const=("split", None),
+            dest="arguments",
+            help=_("split value in several options"),
+        )
+        self.parser.add_argument(
+            "-E",
+            "--empty",
+            action="append",
+            type=self.opt("empty"),
+            dest="arguments",
+            help=_("action to do on empty value ({choices})").format(
+                choices=", ".join(OPT_EMPTY_CHOICES)
+            ),
+        )
+
+    def filter(self, filter_type, filter_arg, value):
+        if filter_type == "split":
+            return value.split()
+        elif filter_type == "empty":
+            if filter_arg == OPT_EMPTY_IGNORE:
+                return value if value else None
+            elif filter_arg == OPT_EMPTY_SKIP:
+                return value if value else False
+            else:
+                self.parser.error(
+                    _("--empty value must be one of {choices}").format(
+                        choices=", ".join(OPT_EMPTY_CHOICES)
+                    )
+                )
+
+        super(Csv, self).filter(filter_type, filter_arg, value)
+
+    async def start(self):
+        import csv
+
+        if self.args.encoding:
+            sys.stdin.reconfigure(encoding=self.args.encoding, errors="replace")
+        reader = csv.reader(sys.stdin)
+        for idx, row in enumerate(reader):
+            try:
+                if idx < self.args.row:
+                    continue
+                for value in row:
+                    self.add_value(value)
+                await self.runCommand()
+            except exceptions.CancelError:
+                #  this row has been cancelled, we skip it
+                continue
+
+        self.host.quit()
+
+
+class Input(base.CommandBase):
+    subcommands = (Csv,)
+
+    def __init__(self, host):
+        super(Input, self).__init__(
+            host,
+            "input",
+            use_profile=False,
+            help=_("launch command with external input"),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_invitation.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import data_format
+
+__commands__ = ["Invitation"]
+
+
+class Create(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "create",
+            use_profile=False,
+            use_output=C.OUTPUT_DICT,
+            help=_("create and send an invitation"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            default="",
+            help="jid of the invitee (default: generate one)",
+        )
+        self.parser.add_argument(
+            "-P",
+            "--password",
+            default="",
+            help="password of the invitee profile/XMPP account (default: generate one)",
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help="name of the invitee",
+        )
+        self.parser.add_argument(
+            "-N",
+            "--host-name",
+            default="",
+            help="name of the host",
+        )
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            action="append",
+            default=[],
+            help="email(s) to send the invitation to (if --no-email is set, email will just be saved)",
+        )
+        self.parser.add_argument(
+            "--no-email", action="store_true", help="do NOT send invitation email"
+        )
+        self.parser.add_argument(
+            "-l",
+            "--lang",
+            default="",
+            help="main language spoken by the invitee",
+        )
+        self.parser.add_argument(
+            "-u",
+            "--url",
+            default="",
+            help="template to construct the URL",
+        )
+        self.parser.add_argument(
+            "-s",
+            "--subject",
+            default="",
+            help="subject of the invitation email (default: generic subject)",
+        )
+        self.parser.add_argument(
+            "-b",
+            "--body",
+            default="",
+            help="body of the invitation email (default: generic body)",
+        )
+        self.parser.add_argument(
+            "-x",
+            "--extra",
+            metavar=("KEY", "VALUE"),
+            action="append",
+            nargs=2,
+            default=[],
+            help="extra data to associate with invitation/invitee",
+        )
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default="",
+            help="profile doing the invitation (default: don't associate profile)",
+        )
+
+    async def start(self):
+        extra = dict(self.args.extra)
+        email = self.args.email[0] if self.args.email else None
+        emails_extra = self.args.email[1:]
+        if self.args.no_email:
+            if email:
+                extra["email"] = email
+                data_format.iter2dict("emails_extra", emails_extra)
+        else:
+            if not email:
+                self.parser.error(
+                    _("you need to specify an email address to send email invitation")
+                )
+
+        try:
+            invitation_data = await self.host.bridge.invitation_create(
+                email,
+                emails_extra,
+                self.args.jid,
+                self.args.password,
+                self.args.name,
+                self.args.host_name,
+                self.args.lang,
+                self.args.url,
+                self.args.subject,
+                self.args.body,
+                extra,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(invitation_data)
+            self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_profile=False,
+            use_output=C.OUTPUT_DICT,
+            help=_("get invitation data"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("id", help=_("invitation UUID"))
+        self.parser.add_argument(
+            "-j",
+            "--with-jid",
+            action="store_true",
+            help=_("start profile session and retrieve jid"),
+        )
+
+    async def output_data(self, data, jid_=None):
+        if jid_ is not None:
+            data["jid"] = jid_
+        await self.output(data)
+        self.host.quit()
+
+    async def start(self):
+        try:
+            invitation_data = await self.host.bridge.invitation_get(
+                self.args.id,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't get invitation data: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if not self.args.with_jid:
+            await self.output_data(invitation_data)
+        else:
+            profile = invitation_data["guest_profile"]
+            try:
+                await self.host.bridge.profile_start_session(
+                    invitation_data["password"],
+                    profile,
+                )
+            except Exception as e:
+                self.disp(msg=_("can't start session: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            try:
+                jid_ = await self.host.bridge.param_get_a_async(
+                    "JabberID",
+                    "Connection",
+                    profile_key=profile,
+                )
+            except Exception as e:
+                self.disp(msg=_("can't retrieve jid: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            await self.output_data(invitation_data, jid_)
+
+
+class Delete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_profile=False,
+            help=_("delete guest account"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("id", help=_("invitation UUID"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.invitation_delete(
+                self.args.id,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't delete guest account: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class Modify(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "modify", use_profile=False, help=_("modify existing invitation")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--replace", action="store_true", help="replace the whole data"
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help="name of the invitee",
+        )
+        self.parser.add_argument(
+            "-N",
+            "--host-name",
+            default="",
+            help="name of the host",
+        )
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            default="",
+            help="email to send the invitation to (if --no-email is set, email will just be saved)",
+        )
+        self.parser.add_argument(
+            "-l",
+            "--lang",
+            dest="language",
+            default="",
+            help="main language spoken by the invitee",
+        )
+        self.parser.add_argument(
+            "-x",
+            "--extra",
+            metavar=("KEY", "VALUE"),
+            action="append",
+            nargs=2,
+            default=[],
+            help="extra data to associate with invitation/invitee",
+        )
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default="",
+            help="profile doing the invitation (default: don't associate profile",
+        )
+        self.parser.add_argument("id", help=_("invitation UUID"))
+
+    async def start(self):
+        extra = dict(self.args.extra)
+        for arg_name in ("name", "host_name", "email", "language", "profile"):
+            value = getattr(self.args, arg_name)
+            if not value:
+                continue
+            if arg_name in extra:
+                self.parser.error(
+                    _(
+                        "you can't set {arg_name} in both optional argument and extra"
+                    ).format(arg_name=arg_name)
+                )
+            extra[arg_name] = value
+        try:
+            await self.host.bridge.invitation_modify(
+                self.args.id,
+                extra,
+                self.args.replace,
+            )
+        except Exception as e:
+            self.disp(f"can't modify invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("invitations have been modified successfuly"))
+            self.host.quit(C.EXIT_OK)
+
+
+class List(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "list",
+            use_profile=False,
+            use_output=C.OUTPUT_COMPLEX,
+            extra_outputs=extra_outputs,
+            help=_("list invitations data"),
+        )
+
+    def default_output(self, data):
+        for idx, datum in enumerate(data.items()):
+            if idx:
+                self.disp("\n")
+            key, invitation_data = datum
+            self.disp(A.color(C.A_HEADER, key))
+            indent = "  "
+            for k, v in invitation_data.items():
+                self.disp(indent + A.color(C.A_SUBHEADER, k + ":") + " " + str(v))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default=C.PROF_KEY_NONE,
+            help=_("return only invitations linked to this profile"),
+        )
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.invitation_list(
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"return only invitations linked to this profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(data)
+            self.host.quit()
+
+
+class Invitation(base.CommandBase):
+    subcommands = (Create, Get, Delete, Modify, List)
+
+    def __init__(self, host):
+        super(Invitation, self).__init__(
+            host,
+            "invitation",
+            use_profile=False,
+            help=_("invitation of user(s) without XMPP account"),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_list.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import json
+import os
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli import common
+from libervia.cli.constants import Const as C
+from . import base
+
+__commands__ = ["List"]
+
+FIELDS_MAP = "mapping"
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS},
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            use_output=C.OUTPUT_LIST_XMLUI,
+            help=_("get lists"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
+        try:
+            lists_data = data_format.deserialise(
+                await self.host.bridge.list_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    "",
+                    self.get_pubsub_extra(),
+                    self.profile,
+                ),
+                type_check=list,
+            )
+        except Exception as e:
+            self.disp(f"can't get lists: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(lists_data[0])
+            self.host.quit(C.EXIT_OK)
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("set a list item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs="+",
+            dest="fields",
+            required=True,
+            metavar=("NAME", "VALUES"),
+            help=_("field(s) to set (required)"),
+        )
+        self.parser.add_argument(
+            "-U",
+            "--update",
+            choices=("auto", "true", "false"),
+            default="auto",
+            help=_("update existing item instead of replacing it (DEFAULT: auto)"),
+        )
+        self.parser.add_argument(
+            "item",
+            nargs="?",
+            default="",
+            help=_("id, URL of the item to update, or nothing for new item"),
+        )
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
+        if self.args.update == "auto":
+            # we update if we have a item id specified
+            update = bool(self.args.item)
+        else:
+            update = C.bool(self.args.update)
+
+        values = {}
+
+        for field_data in self.args.fields:
+            values.setdefault(field_data[0], []).extend(field_data[1:])
+
+        extra = {"update": update}
+
+        try:
+            item_id = await self.host.bridge.list_set(
+                self.args.service,
+                self.args.node,
+                values,
+                "",
+                self.args.item,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set list item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(f"item {str(item_id or self.args.item)!r} set successfully")
+            self.host.quit(C.EXIT_OK)
+
+
+class Delete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("delete a list item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true", help=_("delete without confirmation")
+        )
+        self.parser.add_argument(
+            "-N", "--notify", action="store_true", help=_("notify deletion")
+        )
+        self.parser.add_argument(
+            "item",
+            help=_("id of the item to delete"),
+        )
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
+        if not self.args.item:
+            self.parser.error(_("You need to specify a list item to delete"))
+        if not self.args.force:
+            message = _("Are you sure to delete list item {item_id} ?").format(
+                item_id=self.args.item
+            )
+            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
+        try:
+            await self.host.bridge.list_delete_item(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.notify,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't delete item: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("item {item} has been deleted").format(item=self.args.item))
+            self.host.quit(C.EXIT_OK)
+
+
+class Import(base.CommandBase):
+    # TODO: factorize with blog/import
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "import",
+            use_progress=True,
+            use_verbose=True,
+            help=_("import tickets from external software/dataset"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "importer",
+            nargs="?",
+            help=_("importer name, nothing to display importers list"),
+        )
+        self.parser.add_argument(
+            "-o",
+            "--option",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar=("NAME", "VALUE"),
+            help=_("importer specific options (see importer description)"),
+        )
+        self.parser.add_argument(
+            "-m",
+            "--map",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar=("IMPORTED_FIELD", "DEST_FIELD"),
+            help=_(
+                "specified field in import data will be put in dest field (default: use "
+                "same field name, or ignore if it doesn't exist)"
+            ),
+        )
+        self.parser.add_argument(
+            "-s",
+            "--service",
+            default="",
+            metavar="PUBSUB_SERVICE",
+            help=_("PubSub service where the items must be uploaded (default: server)"),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--node",
+            default="",
+            metavar="PUBSUB_NODE",
+            help=_(
+                "PubSub node where the items must be uploaded (default: tickets' "
+                "defaults)"
+            ),
+        )
+        self.parser.add_argument(
+            "location",
+            nargs="?",
+            help=_(
+                "importer data location (see importer description), nothing to show "
+                "importer description"
+            ),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("Tickets upload started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("Tickets uploaded successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        self.disp(
+            _("Error while uploading tickets: {error_msg}").format(error_msg=error_msg),
+            error=True,
+        )
+
+    async def start(self):
+        if self.args.location is None:
+            # no location, the list of importer or description is requested
+            for name in ("option", "service", "node"):
+                if getattr(self.args, name):
+                    self.parser.error(
+                        _(
+                            "{name} argument can't be used without location argument"
+                        ).format(name=name)
+                    )
+            if self.args.importer is None:
+                self.disp(
+                    "\n".join(
+                        [
+                            f"{name}: {desc}"
+                            for name, desc in await self.host.bridge.ticketsImportList()
+                        ]
+                    )
+                )
+            else:
+                try:
+                    short_desc, long_desc = await self.host.bridge.ticketsImportDesc(
+                        self.args.importer
+                    )
+                except Exception as e:
+                    self.disp(f"can't get importer description: {e}", error=True)
+                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                else:
+                    self.disp(f"{name}: {short_desc}\n\n{long_desc}")
+            self.host.quit()
+        else:
+            # we have a location, an import is requested
+
+            if self.args.progress:
+                # we use a custom progress bar template as we want a counter
+                self.pbar_template = [
+                    _("Progress: "),
+                    ["Percentage"],
+                    " ",
+                    ["Bar"],
+                    " ",
+                    ["Counter"],
+                    " ",
+                    ["ETA"],
+                ]
+
+            options = {key: value for key, value in self.args.option}
+            fields_map = dict(self.args.map)
+            if fields_map:
+                if FIELDS_MAP in options:
+                    self.parser.error(
+                        _(
+                            "fields_map must be specified either preencoded in --option or "
+                            "using --map, but not both at the same time"
+                        )
+                    )
+                options[FIELDS_MAP] = json.dumps(fields_map)
+
+            try:
+                progress_id = await self.host.bridge.ticketsImport(
+                    self.args.importer,
+                    self.args.location,
+                    options,
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("Error while trying to import tickets: {e}").format(e=e),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                await self.set_progress_id(progress_id)
+
+
+class List(base.CommandBase):
+    subcommands = (Get, Set, Delete, Import)
+
+    def __init__(self, host):
+        super(List, self).__init__(
+            host, "list", use_profile=False, help=_("pubsub lists handling")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_merge_request.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import os.path
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli.constants import Const as C
+from libervia.cli import xmlui_manager
+from libervia.cli import common
+
+__commands__ = ["MergeRequest"]
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("publish or update a merge request"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-i",
+            "--item",
+            default="",
+            help=_("id or URL of the request to update, or nothing for a new one"),
+        )
+        self.parser.add_argument(
+            "-r",
+            "--repository",
+            metavar="PATH",
+            default=".",
+            help=_("path of the repository (DEFAULT: current directory)"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("publish merge request without confirmation"),
+        )
+        self.parser.add_argument(
+            "-l",
+            "--label",
+            dest="labels",
+            action="append",
+            help=_("labels to categorize your request"),
+        )
+
+    async def start(self):
+        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
+        await common.fill_well_known_uri(self, self.repository, "merge requests")
+        if not self.args.force:
+            message = _(
+                "You are going to publish your changes to service "
+                "[{service}], are you sure ?"
+            ).format(service=self.args.service)
+            await self.host.confirm_or_quit(
+                message, _("merge request publication cancelled")
+            )
+
+        extra = {"update": True} if self.args.item else {}
+        values = {}
+        if self.args.labels is not None:
+            values["labels"] = self.args.labels
+        try:
+            published_id = await self.host.bridge.merge_request_set(
+                self.args.service,
+                self.args.node,
+                self.repository,
+                "auto",
+                values,
+                "",
+                self.args.item,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create merge requests: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if published_id:
+            self.disp(
+                _("Merge request published at {published_id}").format(
+                    published_id=published_id
+                )
+            )
+        else:
+            self.disp(_("Merge request published"))
+
+        self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS},
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("get a merge request"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "merge requests", meta_map={})
+        extra = {}
+        try:
+            requests_data = data_format.deserialise(
+                await self.host.bridge.merge_requests_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    "",
+                    data_format.serialise(extra),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't get merge request: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if self.verbosity >= 1:
+            whitelist = None
+        else:
+            whitelist = {"id", "title", "body"}
+        for request_xmlui in requests_data["items"]:
+            xmlui = xmlui_manager.create(self.host, request_xmlui, whitelist=whitelist)
+            await xmlui.show(values_only=True)
+            self.disp("")
+        self.host.quit(C.EXIT_OK)
+
+
+class Import(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "import",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM, C.ITEM},
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("import a merge request"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-r",
+            "--repository",
+            metavar="PATH",
+            default=".",
+            help=_("path of the repository (DEFAULT: current directory)"),
+        )
+
+    async def start(self):
+        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
+        await common.fill_well_known_uri(
+            self, self.repository, "merge requests", meta_map={}
+        )
+        extra = {}
+        try:
+            await self.host.bridge.merge_requests_import(
+                self.repository,
+                self.args.item,
+                self.args.service,
+                self.args.node,
+                extra,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't import merge request: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class MergeRequest(base.CommandBase):
+    subcommands = (Set, Get, Import)
+
+    def __init__(self, host):
+        super(MergeRequest, self).__init__(
+            host, "merge-request", use_profile=False, help=_("merge-request management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_message.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+import sys
+
+from twisted.python import filepath
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.utils import clean_ustr
+from libervia.cli import base
+from libervia.cli.constants import Const as C
+from libervia.frontends.tools import jid
+
+
+__commands__ = ["Message"]
+
+
+class Send(base.CommandBase):
+    def __init__(self, host):
+        super(Send, self).__init__(host, "send", help=_("send a message to a contact"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-l", "--lang", type=str, default="", help=_("language of the message")
+        )
+        self.parser.add_argument(
+            "-s",
+            "--separate",
+            action="store_true",
+            help=_(
+                "separate xmpp messages: send one message per line instead of one "
+                "message alone."
+            ),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--new-line",
+            action="store_true",
+            help=_(
+                "add a new line at the beginning of the input"
+            ),
+        )
+        self.parser.add_argument(
+            "-S",
+            "--subject",
+            help=_("subject of the message"),
+        )
+        self.parser.add_argument(
+            "-L", "--subject-lang", type=str, default="", help=_("language of subject")
+        )
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,),
+            default=C.MESS_TYPE_AUTO,
+            help=_("type of the message"),
+        )
+        self.parser.add_argument("-e", "--encrypt", metavar="ALGORITHM",
+                                 help=_("encrypt message using given algorithm"))
+        self.parser.add_argument(
+            "--encrypt-noreplace",
+            action="store_true",
+            help=_("don't replace encryption algorithm if an other one is already used"))
+        self.parser.add_argument(
+            "-a", "--attach", dest="attachments", action="append", metavar="FILE_PATH",
+            help=_("add a file as an attachment")
+        )
+        syntax = self.parser.add_mutually_exclusive_group()
+        syntax.add_argument("-x", "--xhtml", action="store_true", help=_("XHTML body"))
+        syntax.add_argument("-r", "--rich", action="store_true", help=_("rich body"))
+        self.parser.add_argument(
+            "jid", help=_("the destination jid")
+        )
+
+    async def send_stdin(self, dest_jid):
+        """Send incomming data on stdin to jabber contact
+
+        @param dest_jid: destination jid
+        """
+        header = "\n" if self.args.new_line else ""
+        # FIXME: stdin is not read asynchronously at the moment
+        stdin_lines = [
+            stream for stream in sys.stdin.readlines()
+        ]
+        extra = {}
+        if self.args.subject is None:
+            subject = {}
+        else:
+            subject = {self.args.subject_lang: self.args.subject}
+
+        if self.args.xhtml or self.args.rich:
+            key = "xhtml" if self.args.xhtml else "rich"
+            if self.args.lang:
+                key = f"{key}_{self.args.lang}"
+            extra[key] = clean_ustr("".join(stdin_lines))
+            stdin_lines = []
+
+        to_send = []
+
+        error = False
+
+        if self.args.separate:
+            # we send stdin in several messages
+            if header:
+                # first we sent the header
+                try:
+                    await self.host.bridge.message_send(
+                        dest_jid,
+                        {self.args.lang: header},
+                        subject,
+                        self.args.type,
+                        profile_key=self.profile,
+                    )
+                except Exception as e:
+                    self.disp(f"can't send header: {e}", error=True)
+                    error = True
+
+            to_send.extend({self.args.lang: clean_ustr(l.replace("\n", ""))}
+                           for l in stdin_lines)
+        else:
+            # we sent all in a single message
+            if not (self.args.xhtml or self.args.rich):
+                msg = {self.args.lang: header + clean_ustr("".join(stdin_lines))}
+            else:
+                msg = {}
+            to_send.append(msg)
+
+        if self.args.attachments:
+            attachments = extra[C.KEY_ATTACHMENTS] = []
+            for attachment in self.args.attachments:
+                try:
+                    file_path = str(Path(attachment).resolve(strict=True))
+                except FileNotFoundError:
+                    self.disp("file {attachment} doesn't exists, ignoring", error=True)
+                else:
+                    attachments.append({"path": file_path})
+
+        for idx, msg in enumerate(to_send):
+            if idx > 0 and C.KEY_ATTACHMENTS in extra:
+                # if we send several messages, we only want to send attachments with the
+                # first one
+                del extra[C.KEY_ATTACHMENTS]
+            try:
+                await self.host.bridge.message_send(
+                    dest_jid,
+                    msg,
+                    subject,
+                    self.args.type,
+                    data_format.serialise(extra),
+                    profile_key=self.host.profile)
+            except Exception as e:
+                self.disp(f"can't send message {msg!r}: {e}", error=True)
+                error = True
+
+        if error:
+            # at least one message sending failed
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+    async def start(self):
+        if self.args.xhtml and self.args.separate:
+            self.disp(
+                "argument -s/--separate is not compatible yet with argument -x/--xhtml",
+                error=True,
+            )
+            self.host.quit(C.EXIT_BAD_ARG)
+
+        jids = await self.host.check_jids([self.args.jid])
+        jid_ = jids[0]
+
+        if self.args.encrypt_noreplace and self.args.encrypt is None:
+            self.parser.error("You need to use --encrypt if you use --encrypt-noreplace")
+
+        if self.args.encrypt is not None:
+            try:
+                namespace = await self.host.bridge.encryption_namespace_get(
+                    self.args.encrypt)
+            except Exception as e:
+                self.disp(f"can't get encryption namespace: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            try:
+                await self.host.bridge.message_encryption_start(
+                    jid_, namespace, not self.args.encrypt_noreplace, self.profile
+                )
+            except Exception as e:
+                self.disp(f"can't start encryption session: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        await self.send_stdin(jid_)
+
+
+class Retract(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(host, "retract", help=_("retract a message"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "message_id",
+            help=_("ID of the message (internal ID)")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.message_retract(
+                self.args.message_id,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't retract message: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                "message retraction has been requested, please note that this is a "
+                "request which can't be enforced (see documentation for details).")
+            self.host.quit(C.EXIT_OK)
+
+
+class MAM(base.CommandBase):
+
+    def __init__(self, host):
+        super(MAM, self).__init__(
+            host, "mam", use_output=C.OUTPUT_MESS, use_verbose=True,
+            help=_("query archives using MAM"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-s", "--service", default="",
+            help=_("jid of the service (default: profile's server"))
+        self.parser.add_argument(
+            "-S", "--start", dest="mam_start", type=base.date_decoder,
+            help=_(
+                "start fetching archive from this date (default: from the beginning)"))
+        self.parser.add_argument(
+            "-E", "--end", dest="mam_end", type=base.date_decoder,
+            help=_("end fetching archive after this date (default: no limit)"))
+        self.parser.add_argument(
+            "-W", "--with", dest="mam_with",
+            help=_("retrieve only archives with this jid"))
+        self.parser.add_argument(
+            "-m", "--max", dest="rsm_max", type=int, default=20,
+            help=_("maximum number of items to retrieve, using RSM (default: 20))"))
+        rsm_page_group = self.parser.add_mutually_exclusive_group()
+        rsm_page_group.add_argument(
+            "-a", "--after", dest="rsm_after",
+            help=_("find page after this item"), metavar='ITEM_ID')
+        rsm_page_group.add_argument(
+            "-b", "--before", dest="rsm_before",
+            help=_("find page before this item"), metavar='ITEM_ID')
+        rsm_page_group.add_argument(
+            "--index", dest="rsm_index", type=int,
+            help=_("index of the page to retrieve"))
+
+    async def start(self):
+        extra = {}
+        if self.args.mam_start is not None:
+            extra["mam_start"] = float(self.args.mam_start)
+        if self.args.mam_end is not None:
+            extra["mam_end"] = float(self.args.mam_end)
+        if self.args.mam_with is not None:
+            extra["mam_with"] = self.args.mam_with
+        for suff in ('max', 'after', 'before', 'index'):
+            key = 'rsm_' + suff
+            value = getattr(self.args,key)
+            if value is not None:
+                extra[key] = str(value)
+        try:
+            data, metadata_s, profile = await self.host.bridge.mam_get(
+                self.args.service, data_format.serialise(extra), self.profile)
+        except Exception as e:
+            self.disp(f"can't retrieve MAM archives: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        metadata = data_format.deserialise(metadata_s)
+
+        try:
+            session_info = await self.host.bridge.session_infos_get(self.profile)
+        except Exception as e:
+            self.disp(f"can't get session infos: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        # we need to fill own_jid for message output
+        self.host.own_jid = jid.JID(session_info["jid"])
+
+        await self.output(data)
+
+        # FIXME: metadata are not displayed correctly and don't play nice with output
+        #        they should be added to output data somehow
+        if self.verbosity:
+            for value in ("rsm_first", "rsm_last", "rsm_index", "rsm_count",
+                          "mam_complete", "mam_stable"):
+                if value in metadata:
+                    label = value.split("_")[1]
+                    self.disp(A.color(
+                        C.A_HEADER, label, ': ' , A.RESET, metadata[value]))
+
+        self.host.quit()
+
+
+class Message(base.CommandBase):
+    subcommands = (Send, Retract, MAM)
+
+    def __init__(self, host):
+        super(Message, self).__init__(
+            host, "message", use_profile=False, help=_("messages handling")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_param.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from libervia.backend.core.i18n import _
+from .constants import Const as C
+
+__commands__ = ["Param"]
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        super(Get, self).__init__(
+            host, "get", need_connect=False, help=_("get a parameter value")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "category", nargs="?", help=_("category of the parameter")
+        )
+        self.parser.add_argument("name", nargs="?", help=_("name of the parameter"))
+        self.parser.add_argument(
+            "-a",
+            "--attribute",
+            type=str,
+            default="value",
+            help=_("name of the attribute to get"),
+        )
+        self.parser.add_argument(
+            "--security-limit", type=int, default=-1, help=_("security limit")
+        )
+
+    async def start(self):
+        if self.args.category is None:
+            categories = await self.host.bridge.params_categories_get()
+            print("\n".join(categories))
+        elif self.args.name is None:
+            try:
+                values_dict = await self.host.bridge.params_values_from_category_get_async(
+                    self.args.category, self.args.security_limit, "", "", self.profile
+                )
+            except Exception as e:
+                self.disp(
+                    _("can't find requested parameters: {e}").format(e=e), error=True
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                for name, value in values_dict.items():
+                    print(f"{name}\t{value}")
+        else:
+            try:
+                value = await self.host.bridge.param_get_a_async(
+                    self.args.name,
+                    self.args.category,
+                    self.args.attribute,
+                    self.args.security_limit,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("can't find requested parameter: {e}").format(e=e), error=True
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                print(value)
+        self.host.quit()
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        super(Set, self).__init__(
+            host, "set", need_connect=False, help=_("set a parameter value")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("category", help=_("category of the parameter"))
+        self.parser.add_argument("name", help=_("name of the parameter"))
+        self.parser.add_argument("value", help=_("name of the parameter"))
+        self.parser.add_argument(
+            "--security-limit", type=int, default=-1, help=_("security limit")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.param_set(
+                self.args.name,
+                self.args.value,
+                self.args.category,
+                self.args.security_limit,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't set requested parameter: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class SaveTemplate(base.CommandBase):
+    # FIXME: this should probably be removed, it's not used and not useful for end-user
+
+    def __init__(self, host):
+        super(SaveTemplate, self).__init__(
+            host,
+            "save",
+            use_profile=False,
+            help=_("save parameters template to xml file"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("filename", type=str, help=_("output file"))
+
+    async def start(self):
+        """Save parameters template to XML file"""
+        try:
+            await self.host.bridge.params_template_save(self.args.filename)
+        except Exception as e:
+            self.disp(_("can't save parameters to file: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _("parameters saved to file {filename}").format(
+                    filename=self.args.filename
+                )
+            )
+            self.host.quit()
+
+
+class LoadTemplate(base.CommandBase):
+    # FIXME: this should probably be removed, it's not used and not useful for end-user
+
+    def __init__(self, host):
+        super(LoadTemplate, self).__init__(
+            host,
+            "load",
+            use_profile=False,
+            help=_("load parameters template from xml file"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("filename", type=str, help=_("input file"))
+
+    async def start(self):
+        """Load parameters template from xml file"""
+        try:
+            self.host.bridge.params_template_load(self.args.filename)
+        except Exception as e:
+            self.disp(_("can't load parameters from file: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _("parameters loaded from file {filename}").format(
+                    filename=self.args.filename
+                )
+            )
+            self.host.quit()
+
+
+class Param(base.CommandBase):
+    subcommands = (Get, Set, SaveTemplate, LoadTemplate)
+
+    def __init__(self, host):
+        super(Param, self).__init__(
+            host, "param", use_profile=False, help=_("Save/load parameters template")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_ping.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+
+__commands__ = ["Ping"]
+
+
+class Ping(base.CommandBase):
+    def __init__(self, host):
+        super(Ping, self).__init__(host, "ping", help=_("ping XMPP entity"))
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", help=_("jid to ping"))
+        self.parser.add_argument(
+            "-d", "--delay-only", action="store_true", help=_("output delay only (in s)")
+        )
+
+    async def start(self):
+        try:
+            pong_time = await self.host.bridge.ping(self.args.jid, self.profile)
+        except Exception as e:
+            self.disp(msg=_("can't do the ping: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            msg = pong_time if self.args.delay_only else f"PONG ({pong_time} s)"
+            self.disp(msg)
+            self.host.quit()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_pipe.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+import errno
+from functools import partial
+import socket
+import sys
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.cli import base
+from libervia.cli import xmlui_manager
+from libervia.cli.constants import Const as C
+from libervia.frontends.tools import jid
+
+__commands__ = ["Pipe"]
+
+START_PORT = 9999
+
+
+class PipeOut(base.CommandBase):
+    def __init__(self, host):
+        super(PipeOut, self).__init__(host, "out", help=_("send a pipe a stream"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid", help=_("the destination jid")
+        )
+
+    async def start(self):
+        """ Create named pipe, and send stdin to it """
+        try:
+            port = await self.host.bridge.stream_out(
+                await self.host.get_full_jid(self.args.jid),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't start stream: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            # FIXME: we use temporarily blocking code here, as it simplify
+            #        asyncio port: "loop.connect_read_pipe(lambda: reader_protocol,
+            #        sys.stdin.buffer)" doesn't work properly when a file is piped in
+            #        (we get a "ValueError: Pipe transport is for pipes/sockets only.")
+            #        while it's working well for simple text sending.
+
+            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            s.connect(("127.0.0.1", int(port)))
+
+            while True:
+                buf = sys.stdin.buffer.read(4096)
+                if not buf:
+                    break
+                try:
+                    s.sendall(buf)
+                except socket.error as e:
+                    if e.errno == errno.EPIPE:
+                        sys.stderr.write(f"e\n")
+                        self.host.quit(1)
+                    else:
+                        raise e
+            self.host.quit()
+
+
+async def handle_stream_in(reader, writer, host):
+    """Write all received data to stdout"""
+    while True:
+        data = await reader.read(4096)
+        if not data:
+            break
+        sys.stdout.buffer.write(data)
+        try:
+            sys.stdout.flush()
+        except IOError as e:
+            sys.stderr.write(f"{e}\n")
+            break
+    host.quit_from_signal()
+
+
+class PipeIn(base.CommandAnswering):
+    def __init__(self, host):
+        super(PipeIn, self).__init__(host, "in", help=_("receive a pipe stream"))
+        self.action_callbacks = {"STREAM": self.on_stream_action}
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jids",
+            nargs="*",
+            help=_('Jids accepted (none means "accept everything")'),
+        )
+
+    def get_xmlui_id(self, action_data):
+        try:
+            xml_ui = action_data["xmlui"]
+        except KeyError:
+            self.disp(_("Action has no XMLUI"), 1)
+        else:
+            ui = xmlui_manager.create(self.host, xml_ui)
+            if not ui.submit_id:
+                self.disp(_("Invalid XMLUI received"), error=True)
+                self.quit_from_signal(C.EXIT_INTERNAL_ERROR)
+            return ui.submit_id
+
+    async def on_stream_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            self.host.quit_from_signal(C.EXIT_ERROR)
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except KeyError:
+            self.disp(_("Ignoring action without from_jid data"), error=True)
+            return
+
+        if not self.bare_jids or from_jid.bare in self.bare_jids:
+            host, port = "localhost", START_PORT
+            while True:
+                try:
+                    server = await asyncio.start_server(
+                        partial(handle_stream_in, host=self.host), host, port)
+                except socket.error as e:
+                    if e.errno == errno.EADDRINUSE:
+                        port += 1
+                    else:
+                        raise e
+                else:
+                    break
+            xmlui_data = {"answer": C.BOOL_TRUE, "port": str(port)}
+            await self.host.bridge.action_launch(
+                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+            )
+            async with server:
+                await server.serve_forever()
+            self.host.quit_from_signal()
+
+    async def start(self):
+        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
+        await self.start_answering()
+
+
+class Pipe(base.CommandBase):
+    subcommands = (PipeOut, PipeIn)
+
+    def __init__(self, host):
+        super(Pipe, self).__init__(
+            host, "pipe", use_profile=False, help=_("stream piping through XMPP")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_profile.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""This module permits to manage profiles. It can list, create, delete
+and retrieve information about a profile."""
+
+from libervia.cli.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.cli import base
+
+log = getLogger(__name__)
+
+
+__commands__ = ["Profile"]
+
+PROFILE_HELP = _('The name of the profile')
+
+
+class ProfileConnect(base.CommandBase):
+    """Dummy command to use profile_session parent, i.e. to be able to connect without doing anything else"""
+
+    def __init__(self, host):
+        # it's weird to have a command named "connect" with need_connect=False, but it can be handy to be able
+        # to launch just the session, so some paradoxes don't hurt
+        super(ProfileConnect, self).__init__(host, 'connect', need_connect=False, help=('connect a profile'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        # connection is already managed by profile common commands
+        # so we just need to check arguments and quit
+        if not self.args.connect and not self.args.start_session:
+            self.parser.error(_("You need to use either --connect or --start-session"))
+        self.host.quit()
+
+class ProfileDisconnect(base.CommandBase):
+
+    def __init__(self, host):
+        super(ProfileDisconnect, self).__init__(host, 'disconnect', need_connect=False, help=('disconnect a profile'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.disconnect(self.args.profile)
+        except Exception as e:
+            self.disp(f"can't disconnect profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class ProfileCreate(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileCreate, self).__init__(
+            host, 'create', use_profile=False, help=('create a new profile'))
+
+    def add_parser_options(self):
+        self.parser.add_argument('profile', type=str, help=_('the name of the profile'))
+        self.parser.add_argument(
+            '-p', '--password', type=str, default='',
+            help=_('the password of the profile'))
+        self.parser.add_argument(
+            '-j', '--jid', type=str, help=_('the jid of the profile'))
+        self.parser.add_argument(
+            '-x', '--xmpp-password', type=str,
+            help=_(
+                'the password of the XMPP account (use profile password if not specified)'
+            ),
+            metavar='PASSWORD')
+        self.parser.add_argument(
+            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
+            const=C.BOOL_TRUE,
+            help=_('connect this profile automatically when backend starts')
+        )
+        self.parser.add_argument(
+            '-C', '--component', default='',
+            help=_('set to component import name (entry point) if this is a component'))
+
+    async def start(self):
+        """Create a new profile"""
+        if self.args.profile in await self.host.bridge.profiles_list_get():
+            self.disp(f"Profile {self.args.profile} already exists.", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERROR)
+        try:
+            await self.host.bridge.profile_create(
+                self.args.profile, self.args.password, self.args.component)
+        except Exception as e:
+            self.disp(f"can't create profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        try:
+            await self.host.bridge.profile_start_session(
+                self.args.password, self.args.profile)
+        except Exception as e:
+            self.disp(f"can't start profile session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if self.args.jid:
+            await self.host.bridge.param_set(
+                "JabberID", self.args.jid, "Connection", profile_key=self.args.profile)
+        xmpp_pwd = self.args.password or self.args.xmpp_password
+        if xmpp_pwd:
+            await self.host.bridge.param_set(
+                "Password", xmpp_pwd, "Connection", profile_key=self.args.profile)
+
+        if self.args.autoconnect is not None:
+            await self.host.bridge.param_set(
+                "autoconnect_backend", self.args.autoconnect, "Connection",
+                profile_key=self.args.profile)
+
+        self.disp(f'profile {self.args.profile} created successfully', 1)
+        self.host.quit()
+
+
+class ProfileDefault(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileDefault, self).__init__(
+            host, 'default', use_profile=False, help=('print default profile'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        print(await self.host.bridge.profile_name_get('@DEFAULT@'))
+        self.host.quit()
+
+
+class ProfileDelete(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileDelete, self).__init__(host, 'delete', use_profile=False, help=('delete a profile'))
+
+    def add_parser_options(self):
+        self.parser.add_argument('profile', type=str, help=PROFILE_HELP)
+        self.parser.add_argument('-f', '--force', action='store_true', help=_('delete profile without confirmation'))
+
+    async def start(self):
+        if self.args.profile not in await self.host.bridge.profiles_list_get():
+            log.error(f"Profile {self.args.profile} doesn't exist.")
+            self.host.quit(C.EXIT_NOT_FOUND)
+        if not self.args.force:
+            message = f"Are you sure to delete profile [{self.args.profile}] ?"
+            cancel_message = "Profile deletion cancelled"
+            await self.host.confirm_or_quit(message, cancel_message)
+
+        await self.host.bridge.profile_delete_async(self.args.profile)
+        self.host.quit()
+
+
+class ProfileInfo(base.CommandBase):
+
+    def __init__(self, host):
+        super(ProfileInfo, self).__init__(
+            host, 'info', need_connect=False, use_output=C.OUTPUT_DICT,
+            help=_('get information about a profile'))
+        self.to_show = [(_("jid"), "Connection", "JabberID"),]
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            '--show-password', action='store_true',
+            help=_('show the XMPP password IN CLEAR TEXT'))
+
+    async def start(self):
+        if self.args.show_password:
+            self.to_show.append((_("XMPP password"), "Connection", "Password"))
+        self.to_show.append((_("autoconnect (backend)"), "Connection",
+                                "autoconnect_backend"))
+        data = {}
+        for label, category, name in self.to_show:
+            try:
+                value = await self.host.bridge.param_get_a_async(
+                    name, category, profile_key=self.host.profile)
+            except Exception as e:
+                self.disp(f"can't get {name}/{category} param: {e}", error=True)
+            else:
+                data[label] = value
+
+        await self.output(data)
+        self.host.quit()
+
+
+class ProfileList(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileList, self).__init__(
+            host, 'list', use_profile=False, use_output='list', help=('list profiles'))
+
+    def add_parser_options(self):
+        group = self.parser.add_mutually_exclusive_group()
+        group.add_argument(
+            '-c', '--clients', action='store_true', help=_('get clients profiles only'))
+        group.add_argument(
+            '-C', '--components', action='store_true',
+            help=('get components profiles only'))
+
+    async def start(self):
+        if self.args.clients:
+            clients, components = True, False
+        elif self.args.components:
+            clients, components = False, True
+        else:
+            clients, components = True, True
+        await self.output(await self.host.bridge.profiles_list_get(clients, components))
+        self.host.quit()
+
+
+class ProfileModify(base.CommandBase):
+
+    def __init__(self, host):
+        super(ProfileModify, self).__init__(
+            host, 'modify', need_connect=False, help=_('modify an existing profile'))
+
+    def add_parser_options(self):
+        profile_pwd_group = self.parser.add_mutually_exclusive_group()
+        profile_pwd_group.add_argument(
+            '-w', '--password', help=_('change the password of the profile'))
+        profile_pwd_group.add_argument(
+            '--disable-password', action='store_true',
+            help=_('disable profile password (dangerous!)'))
+        self.parser.add_argument('-j', '--jid', help=_('the jid of the profile'))
+        self.parser.add_argument(
+            '-x', '--xmpp-password', help=_('change the password of the XMPP account'),
+            metavar='PASSWORD')
+        self.parser.add_argument(
+            '-D', '--default', action='store_true', help=_('set as default profile'))
+        self.parser.add_argument(
+            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
+            const=C.BOOL_TRUE,
+            help=_('connect this profile automatically when backend starts')
+        )
+
+    async def start(self):
+        if self.args.disable_password:
+            self.args.password = ''
+        if self.args.password is not None:
+            await self.host.bridge.param_set(
+                "Password", self.args.password, "General", profile_key=self.host.profile)
+        if self.args.jid is not None:
+            await self.host.bridge.param_set(
+                "JabberID", self.args.jid, "Connection", profile_key=self.host.profile)
+        if self.args.xmpp_password is not None:
+            await self.host.bridge.param_set(
+                "Password", self.args.xmpp_password, "Connection",
+                profile_key=self.host.profile)
+        if self.args.default:
+            await self.host.bridge.profile_set_default(self.host.profile)
+        if self.args.autoconnect is not None:
+            await self.host.bridge.param_set(
+                "autoconnect_backend", self.args.autoconnect, "Connection",
+                profile_key=self.host.profile)
+
+        self.host.quit()
+
+
+class Profile(base.CommandBase):
+    subcommands = (
+        ProfileConnect, ProfileDisconnect, ProfileCreate, ProfileDefault, ProfileDelete,
+        ProfileInfo, ProfileList, ProfileModify)
+
+    def __init__(self, host):
+        super(Profile, self).__init__(
+            host, 'profile', use_profile=False, help=_('profile commands'))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_pubsub.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,3030 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import argparse
+import os.path
+import re
+import sys
+import subprocess
+import asyncio
+import json
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.cli.constants import Const as C
+from libervia.cli import common
+from libervia.cli import arg_tools
+from libervia.cli import xml_tools
+from functools import partial
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import date_utils
+from libervia.frontends.tools import jid, strings
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+
+__commands__ = ["Pubsub"]
+
+PUBSUB_TMP_DIR = "pubsub"
+PUBSUB_SCHEMA_TMP_DIR = PUBSUB_TMP_DIR + "_schema"
+ALLOWED_SUBSCRIPTIONS_OWNER = ("subscribed", "pending", "none")
+
+# TODO: need to split this class in several modules, plugin should handle subcommands
+
+
+class NodeInfo(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "info",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("retrieve node configuration"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            action="append",
+            dest="keys",
+            help=_("data key to filter"),
+        )
+
+    def remove_prefix(self, key):
+        return key[7:] if key.startswith("pubsub#") else key
+
+    def filter_key(self, key):
+        return any((key == k or key == "pubsub#" + k) for k in self.args.keys)
+
+    async def start(self):
+        try:
+            config_dict = await self.host.bridge.ps_node_configuration_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found":
+                self.disp(
+                    f"The node {self.args.node} doesn't exist on {self.args.service}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't get node configuration: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            key_filter = (lambda k: True) if not self.args.keys else self.filter_key
+            config_dict = {
+                self.remove_prefix(k): v for k, v in config_dict.items() if key_filter(k)
+            }
+            await self.output(config_dict)
+            self.host.quit()
+
+
+class NodeCreate(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "create",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("create a node"),
+        )
+
+    @staticmethod
+    def add_node_config_options(parser):
+        parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            default=[],
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set"),
+        )
+        parser.add_argument(
+            "-F",
+            "--full-prefix",
+            action="store_true",
+            help=_('don\'t prepend "pubsub#" prefix to field names'),
+        )
+
+    def add_parser_options(self):
+        self.add_node_config_options(self.parser)
+
+    @staticmethod
+    def get_config_options(args):
+        if not args.full_prefix:
+            return {"pubsub#" + k: v for k, v in args.fields}
+        else:
+            return dict(args.fields)
+
+    async def start(self):
+        options = self.get_config_options(self.args)
+        try:
+            node_id = await self.host.bridge.ps_node_create(
+                self.args.service,
+                self.args.node,
+                options,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't create node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if self.host.verbosity:
+                announce = _("node created successfully: ")
+            else:
+                announce = ""
+            self.disp(announce + node_id)
+            self.host.quit()
+
+
+class NodePurge(base.CommandBase):
+    def __init__(self, host):
+        super(NodePurge, self).__init__(
+            host,
+            "purge",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("purge a node (i.e. remove all items from it)"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("purge node without confirmation"),
+        )
+
+    async def start(self):
+        if not self.args.force:
+            if not self.args.service:
+                message = _(
+                    "Are you sure to purge PEP node [{node}]? This will "
+                    "delete ALL items from it!"
+                ).format(node=self.args.node)
+            else:
+                message = _(
+                    "Are you sure to delete node [{node}] on service "
+                    "[{service}]? This will delete ALL items from it!"
+                ).format(node=self.args.node, service=self.args.service)
+            await self.host.confirm_or_quit(message, _("node purge cancelled"))
+
+        try:
+            await self.host.bridge.ps_node_purge(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't purge node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("node [{node}] purged successfully").format(node=self.args.node))
+            self.host.quit()
+
+
+class NodeDelete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("delete a node"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("delete node without confirmation"),
+        )
+
+    async def start(self):
+        if not self.args.force:
+            if not self.args.service:
+                message = _("Are you sure to delete PEP node [{node}] ?").format(
+                    node=self.args.node
+                )
+            else:
+                message = _(
+                    "Are you sure to delete node [{node}] on " "service [{service}]?"
+                ).format(node=self.args.node, service=self.args.service)
+            await self.host.confirm_or_quit(message, _("node deletion cancelled"))
+
+        try:
+            await self.host.bridge.ps_node_delete(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't delete node: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("node [{node}] deleted successfully").format(node=self.args.node))
+            self.host.quit()
+
+
+class NodeSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set node configuration"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            required=True,
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set (required)"),
+        )
+        self.parser.add_argument(
+            "-F",
+            "--full-prefix",
+            action="store_true",
+            help=_('don\'t prepend "pubsub#" prefix to field names'),
+        )
+
+    def get_key_name(self, k):
+        if self.args.full_prefix or k.startswith("pubsub#"):
+            return k
+        else:
+            return "pubsub#" + k
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_node_configuration_set(
+                self.args.service,
+                self.args.node,
+                {self.get_key_name(k): v for k, v in self.args.fields},
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set node configuration: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("node configuration successful"), 1)
+            self.host.quit()
+
+
+class NodeImport(base.CommandBase):
+    def __init__(self, host):
+        super(NodeImport, self).__init__(
+            host,
+            "import",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("import raw XML to a node"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--admin",
+            action="store_true",
+            help=_("do a pubsub admin request, needed to change publisher"),
+        )
+        self.parser.add_argument(
+            "import_file",
+            type=argparse.FileType(),
+            help=_(
+                "path to the XML file with data to import. The file must contain "
+                "whole XML of each item to import."
+            ),
+        )
+
+    async def start(self):
+        try:
+            element, etree = xml_tools.etree_parse(
+                self, self.args.import_file, reraise=True
+            )
+        except Exception as e:
+            from lxml.etree import XMLSyntaxError
+
+            if isinstance(e, XMLSyntaxError) and e.code == 5:
+                # we have extra content, this probaby means that item are not wrapped
+                # so we wrap them here and try again
+                self.args.import_file.seek(0)
+                xml_buf = "<import>" + self.args.import_file.read() + "</import>"
+                element, etree = xml_tools.etree_parse(self, xml_buf)
+
+                # we reverse element as we expect to have most recently published element first
+                # TODO: make this more explicit and add an option
+        element[:] = reversed(element)
+
+        if not all([i.tag == "{http://jabber.org/protocol/pubsub}item" for i in element]):
+            self.disp(
+                _("You are not using list of pubsub items, we can't import this file"),
+                error=True,
+            )
+            self.host.quit(C.EXIT_DATA_ERROR)
+            return
+
+        items = [etree.tostring(i, encoding="unicode") for i in element]
+        if self.args.admin:
+            method = self.host.bridge.ps_admin_items_send
+        else:
+            self.disp(
+                _(
+                    "Items are imported without using admin mode, publisher can't "
+                    "be changed"
+                )
+            )
+            method = self.host.bridge.ps_items_send
+
+        try:
+            items_ids = await method(
+                self.args.service,
+                self.args.node,
+                items,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if items_ids:
+                self.disp(
+                    _("items published with id(s) {items_ids}").format(
+                        items_ids=", ".join(items_ids)
+                    )
+                )
+            else:
+                self.disp(_("items published"))
+            self.host.quit()
+
+
+class NodeAffiliationsGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("retrieve node affiliations (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            affiliations = await self.host.bridge.ps_node_affiliations_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get node affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(affiliations)
+            self.host.quit()
+
+
+class NodeAffiliationsSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set affiliations (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
+        #      (used to construct dicts) don't work with positional arguments
+        self.parser.add_argument(
+            "-a",
+            "--affiliation",
+            dest="affiliations",
+            metavar=("JID", "AFFILIATION"),
+            required=True,
+            action="append",
+            nargs=2,
+            help=_("entity/affiliation couple(s)"),
+        )
+
+    async def start(self):
+        affiliations = dict(self.args.affiliations)
+        try:
+            await self.host.bridge.ps_node_affiliations_set(
+                self.args.service,
+                self.args.node,
+                affiliations,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set node affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("affiliations have been set"), 1)
+            self.host.quit()
+
+
+class NodeAffiliations(base.CommandBase):
+    subcommands = (NodeAffiliationsGet, NodeAffiliationsSet)
+
+    def __init__(self, host):
+        super(NodeAffiliations, self).__init__(
+            host,
+            "affiliations",
+            use_profile=False,
+            help=_("set or retrieve node affiliations"),
+        )
+
+
+class NodeSubscriptionsGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("retrieve node subscriptions (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--public",
+            action="store_true",
+            help=_("get public subscriptions"),
+        )
+
+    async def start(self):
+        if self.args.public:
+            method = self.host.bridge.ps_public_node_subscriptions_get
+        else:
+            method = self.host.bridge.ps_node_subscriptions_get
+        try:
+            subscriptions = await method(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get node subscriptions: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(subscriptions)
+            self.host.quit()
+
+
+class StoreSubscriptionAction(argparse.Action):
+    """Action which handle subscription parameter for owner
+
+    list is given by pairs: jid and subscription state
+    if subscription state is not specified, it default to "subscribed"
+    """
+
+    def __call__(self, parser, namespace, values, option_string):
+        dest_dict = getattr(namespace, self.dest)
+        while values:
+            jid_s = values.pop(0)
+            try:
+                subscription = values.pop(0)
+            except IndexError:
+                subscription = "subscribed"
+            if subscription not in ALLOWED_SUBSCRIPTIONS_OWNER:
+                parser.error(
+                    _("subscription must be one of {}").format(
+                        ", ".join(ALLOWED_SUBSCRIPTIONS_OWNER)
+                    )
+                )
+            dest_dict[jid_s] = subscription
+
+
+class NodeSubscriptionsSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set/modify subscriptions (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
+        #      (uses to construct dicts) don't work with positional arguments
+        self.parser.add_argument(
+            "-S",
+            "--subscription",
+            dest="subscriptions",
+            default={},
+            nargs="+",
+            metavar=("JID [SUSBSCRIPTION]"),
+            required=True,
+            action=StoreSubscriptionAction,
+            help=_("entity/subscription couple(s)"),
+        )
+
+    async def start(self):
+        try:
+            self.host.bridge.ps_node_subscriptions_set(
+                self.args.service,
+                self.args.node,
+                self.args.subscriptions,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set node subscriptions: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("subscriptions have been set"), 1)
+            self.host.quit()
+
+
+class NodeSubscriptions(base.CommandBase):
+    subcommands = (NodeSubscriptionsGet, NodeSubscriptionsSet)
+
+    def __init__(self, host):
+        super(NodeSubscriptions, self).__init__(
+            host,
+            "subscriptions",
+            use_profile=False,
+            help=_("get or modify node subscriptions"),
+        )
+
+
+class NodeSchemaSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set/replace a schema"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("schema", help=_("schema to set (must be XML)"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_schema_set(
+                self.args.service,
+                self.args.node,
+                self.args.schema,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set schema: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("schema has been set"), 1)
+            self.host.quit()
+
+
+class NodeSchemaEdit(base.CommandBase, common.BaseEdit):
+    use_items = False
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_draft=True,
+            use_verbose=True,
+            help=_("edit a schema"),
+        )
+        common.BaseEdit.__init__(self, self.host, PUBSUB_SCHEMA_TMP_DIR)
+
+    def add_parser_options(self):
+        pass
+
+    async def publish(self, schema):
+        try:
+            await self.host.bridge.ps_schema_set(
+                self.args.service,
+                self.args.node,
+                schema,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set schema: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("schema has been set"), 1)
+            self.host.quit()
+
+    async def ps_schema_get_cb(self, schema):
+        try:
+            from lxml import etree
+        except ImportError:
+            self.disp(
+                "lxml module must be installed to use edit, please install it "
+                'with "pip install lxml"',
+                error=True,
+            )
+            self.host.quit(1)
+        content_file_obj, content_file_path = self.get_tmp_file()
+        schema = schema.strip()
+        if schema:
+            parser = etree.XMLParser(remove_blank_text=True)
+            schema_elt = etree.fromstring(schema, parser)
+            content_file_obj.write(
+                etree.tostring(schema_elt, encoding="utf-8", pretty_print=True)
+            )
+            content_file_obj.seek(0)
+        await self.run_editor(
+            "pubsub_schema_editor_args", content_file_path, content_file_obj
+        )
+
+    async def start(self):
+        try:
+            schema = await self.host.bridge.ps_schema_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                schema = ""
+            else:
+                self.disp(f"can't edit schema: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        await self.ps_schema_get_cb(schema)
+
+
+class NodeSchemaGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_XML,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("get schema"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            schema = await self.host.bridge.ps_schema_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                schema = None
+            else:
+                self.disp(f"can't get schema: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if schema:
+            await self.output(schema)
+            self.host.quit()
+        else:
+            self.disp(_("no schema found"), 1)
+            self.host.quit(C.EXIT_NOT_FOUND)
+
+
+class NodeSchema(base.CommandBase):
+    subcommands = (NodeSchemaSet, NodeSchemaEdit, NodeSchemaGet)
+
+    def __init__(self, host):
+        super(NodeSchema, self).__init__(
+            host, "schema", use_profile=False, help=_("data schema manipulation")
+        )
+
+
+class Node(base.CommandBase):
+    subcommands = (
+        NodeInfo,
+        NodeCreate,
+        NodePurge,
+        NodeDelete,
+        NodeSet,
+        NodeImport,
+        NodeAffiliations,
+        NodeSubscriptions,
+        NodeSchema,
+    )
+
+    def __init__(self, host):
+        super(Node, self).__init__(
+            host, "node", use_profile=False, help=_("node handling")
+        )
+
+
+class CacheGet(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_XML,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
+            help=_("get pubsub item(s) from cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-S",
+            "--sub-id",
+            default="",
+            help=_("subscription id"),
+        )
+
+    async def start(self):
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_cache_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    self.args.sub_id,
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except BridgeException as e:
+            if e.classname == "NotFound":
+                self.disp(
+                    f"The node {self.args.node} from {self.args.service} is not in cache "
+                    f"for {self.profile}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't get pubsub items from cache: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            await self.output(ps_result["items"])
+            self.host.quit(C.EXIT_OK)
+
+
+class CacheSync(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "sync",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("(re)synchronise a pubsub node"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_cache_sync(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                self.disp(
+                    f"The node {self.args.node} doesn't exist on {self.args.service}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't synchronise pubsub node: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class CachePurge(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "purge",
+            use_profile=False,
+            help=_("purge (delete) items from cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-s", "--service", action="append", metavar="JID", dest="services",
+            help="purge items only for these services. If not specified, items from ALL "
+            "services will be purged. May be used several times."
+        )
+        self.parser.add_argument(
+            "-n", "--node", action="append", dest="nodes",
+            help="purge items only for these nodes. If not specified, items from ALL "
+            "nodes will be purged. May be used several times."
+        )
+        self.parser.add_argument(
+            "-p", "--profile", action="append", dest="profiles",
+            help="purge items only for these profiles. If not specified, items from ALL "
+            "profiles will be purged. May be used several times."
+        )
+        self.parser.add_argument(
+            "-b", "--updated-before", type=base.date_decoder, metavar="TIME_PATTERN",
+            help="purge items which have been last updated before given time."
+        )
+        self.parser.add_argument(
+            "-C", "--created-before", type=base.date_decoder, metavar="TIME_PATTERN",
+            help="purge items which have been last created before given time."
+        )
+        self.parser.add_argument(
+            "-t", "--type", action="append", dest="types",
+            help="purge items flagged with TYPE. May be used several times."
+        )
+        self.parser.add_argument(
+            "-S", "--subtype", action="append", dest="subtypes",
+            help="purge items flagged with SUBTYPE. May be used several times."
+        )
+        self.parser.add_argument(
+            "-f", "--force", action="store_true",
+            help=_("purge items without confirmation")
+        )
+
+    async def start(self):
+        if not self.args.force:
+            await self.host.confirm_or_quit(
+                _(
+                    "Are you sure to purge items from cache? You'll have to bypass cache "
+                    "or resynchronise nodes to access deleted items again."
+                ),
+                _("Items purgins has been cancelled.")
+            )
+        purge_data = {}
+        for key in (
+                "services", "nodes", "profiles", "updated_before", "created_before",
+                "types", "subtypes"
+        ):
+            value = getattr(self.args, key)
+            if value is not None:
+                purge_data[key] = value
+        try:
+            await self.host.bridge.ps_cache_purge(
+                data_format.serialise(
+                    purge_data
+                )
+            )
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class CacheReset(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "reset",
+            use_profile=False,
+            help=_("remove everything from cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true",
+            help=_("reset cache without confirmation")
+        )
+
+    async def start(self):
+        if not self.args.force:
+            await self.host.confirm_or_quit(
+                _(
+                    "Are you sure to reset cache? All nodes and items will be removed "
+                    "from it, then it will be progressively refilled as if it were new. "
+                    "This may be resources intensive."
+                ),
+                _("Pubsub cache reset has been cancelled.")
+            )
+        try:
+            await self.host.bridge.ps_cache_reset()
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class CacheSearch(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {
+            "default": self.default_output,
+            "xml": self.xml_output,
+            "xml-raw": self.xml_raw_output,
+        }
+        super().__init__(
+            host,
+            "search",
+            use_profile=False,
+            use_output=C.OUTPUT_LIST_DICT,
+            extra_outputs=extra_outputs,
+            help=_("search for pubsub items in cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--fts", help=_("Full-Text Search query"), metavar="FTS_QUERY"
+        )
+        self.parser.add_argument(
+            "-p", "--profile", action="append", dest="profiles", metavar="PROFILE",
+            help="search items only from these profiles. May be used several times."
+        )
+        self.parser.add_argument(
+            "-s", "--service", action="append", dest="services", metavar="SERVICE",
+            help="items must be from specified service. May be used several times."
+        )
+        self.parser.add_argument(
+            "-n", "--node", action="append", dest="nodes", metavar="NODE",
+            help="items must be in the specified node. May be used several times."
+        )
+        self.parser.add_argument(
+            "-t", "--type", action="append", dest="types", metavar="TYPE",
+            help="items must be of specified type. May be used several times."
+        )
+        self.parser.add_argument(
+            "-S", "--subtype", action="append", dest="subtypes", metavar="SUBTYPE",
+            help="items must be of specified subtype. May be used several times."
+        )
+        self.parser.add_argument(
+            "-P", "--payload", action="store_true", help=_("include item XML payload")
+        )
+        self.parser.add_argument(
+            "-o", "--order-by", action="append", nargs="+",
+            metavar=("ORDER", "[FIELD] [DIRECTION]"),
+            help=_("how items must be ordered. May be used several times.")
+        )
+        self.parser.add_argument(
+            "-l", "--limit", type=int, help=_("maximum number of items to return")
+        )
+        self.parser.add_argument(
+            "-i", "--index", type=int, help=_("return results starting from this index")
+        )
+        self.parser.add_argument(
+            "-F",
+            "--field",
+            action="append",
+            nargs=3,
+            dest="fields",
+            default=[],
+            metavar=("PATH", "OPERATOR", "VALUE"),
+            help=_("parsed data field filter. May be used several times."),
+        )
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            action="append",
+            dest="keys",
+            metavar="KEY",
+            help=_(
+                "data key(s) to display. May be used several times. DEFAULT: show all "
+                "keys"
+            ),
+        )
+
+    async def start(self):
+        query = {}
+        for arg in ("fts", "profiles", "services", "nodes", "types", "subtypes"):
+            value = getattr(self.args, arg)
+            if value:
+                if arg in ("types", "subtypes"):
+                    # empty string is used to find items without type and/or subtype
+                    value = [v or None for v in value]
+                query[arg] = value
+        for arg in ("limit", "index"):
+            value = getattr(self.args, arg)
+            if value is not None:
+                query[arg] = value
+        if self.args.order_by is not None:
+            for order_data in self.args.order_by:
+                order, *args = order_data
+                if order == "field":
+                    if not args:
+                        self.parser.error(_("field data must be specified in --order-by"))
+                    elif len(args) == 1:
+                        path = args[0]
+                        direction = "asc"
+                    elif len(args) == 2:
+                        path, direction = args
+                    else:
+                        self.parser.error(_(
+                            "You can't specify more that 2 arguments for a field in "
+                            "--order-by"
+                        ))
+                    try:
+                        path = json.loads(path)
+                    except json.JSONDecodeError:
+                        pass
+                    order_query = {
+                        "path": path,
+                    }
+                else:
+                    order_query = {
+                        "order": order
+                    }
+                    if not args:
+                        direction = "asc"
+                    elif len(args) == 1:
+                        direction = args[0]
+                    else:
+                        self.parser.error(_(
+                            "there are too many arguments in --order-by option"
+                        ))
+                if direction.lower() not in ("asc", "desc"):
+                    self.parser.error(_("invalid --order-by direction: {direction!r}"))
+                order_query["direction"] = direction
+                query.setdefault("order-by", []).append(order_query)
+
+        if self.args.fields:
+            parsed = []
+            for field in self.args.fields:
+                path, operator, value = field
+                try:
+                    path = json.loads(path)
+                except json.JSONDecodeError:
+                    # this is not a JSON encoded value, we keep it as a string
+                    pass
+
+                if not isinstance(path, list):
+                    path = [path]
+
+                # handling of TP(<time pattern>)
+                if operator in (">", "gt", "<", "le", "between"):
+                    def datetime_sub(match):
+                        return str(date_utils.date_parse_ext(
+                            match.group(1), default_tz=date_utils.TZ_LOCAL
+                        ))
+                    value = re.sub(r"\bTP\(([^)]+)\)", datetime_sub, value)
+
+                try:
+                    value = json.loads(value)
+                except json.JSONDecodeError:
+                    # not JSON, as above we keep it as string
+                    pass
+
+                if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"):
+                    if not isinstance(value, list):
+                        value = [value]
+
+                parsed.append({
+                    "path": path,
+                    "op": operator,
+                    "value": value
+                })
+
+            query["parsed"] = parsed
+
+        if self.args.payload or "xml" in self.args.output:
+            query["with_payload"] = True
+            if self.args.keys:
+                self.args.keys.append("item_payload")
+        try:
+            found_items = data_format.deserialise(
+                await self.host.bridge.ps_cache_search(
+                    data_format.serialise(query)
+                ),
+                type_check=list,
+            )
+        except BridgeException as e:
+            self.disp(f"can't search for pubsub items in cache: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            if self.args.keys:
+                found_items = [
+                    {k: v for k,v in item.items() if k in self.args.keys}
+                    for item in found_items
+                ]
+            await self.output(found_items)
+            self.host.quit(C.EXIT_OK)
+
+    def default_output(self, found_items):
+        for item in found_items:
+            for field in ("created", "published", "updated"):
+                try:
+                    timestamp = item[field]
+                except KeyError:
+                    pass
+                else:
+                    try:
+                        item[field] = common.format_time(timestamp)
+                    except ValueError:
+                        pass
+        self.host._outputs[C.OUTPUT_LIST_DICT]["simple"]["callback"](found_items)
+
+    def xml_output(self, found_items):
+        """Output prettified item payload"""
+        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML]["callback"]
+        for item in found_items:
+            cb(item["item_payload"])
+
+    def xml_raw_output(self, found_items):
+        """Output item payload without prettifying"""
+        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML_RAW]["callback"]
+        for item in found_items:
+            cb(item["item_payload"])
+
+
+class Cache(base.CommandBase):
+    subcommands = (
+        CacheGet,
+        CacheSync,
+        CachePurge,
+        CacheReset,
+        CacheSearch,
+    )
+
+    def __init__(self, host):
+        super(Cache, self).__init__(
+            host, "cache", use_profile=False, help=_("pubsub cache handling")
+        )
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            use_quiet=True,
+            pubsub_flags={C.NODE},
+            help=_("publish a new item or update an existing one"),
+        )
+
+    def add_parser_options(self):
+        NodeCreate.add_node_config_options(self.parser)
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the blog item")
+        )
+        self.parser.add_argument(
+            "--encrypt-for",
+            metavar="JID",
+            action="append",
+            help=_("encrypt a single item for")
+        )
+        self.parser.add_argument(
+            "-X",
+            "--sign",
+            action="store_true",
+            help=_("cryptographically sign the blog post")
+        )
+        self.parser.add_argument(
+            "item",
+            nargs="?",
+            default="",
+            help=_("id, URL of the item to update, keyword, or nothing for new item"),
+        )
+
+    async def start(self):
+        element, etree = xml_tools.etree_parse(self, sys.stdin)
+        element = xml_tools.get_payload(self, element)
+        payload = etree.tostring(element, encoding="unicode")
+        extra = {}
+        if self.args.encrypt:
+            extra["encrypted"] = True
+        if self.args.encrypt_for:
+            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
+        if self.args.sign:
+            extra["signed"] = True
+        publish_options = NodeCreate.get_config_options(self.args)
+        if publish_options:
+            extra["publish_options"] = publish_options
+
+        try:
+            published_id = await self.host.bridge.ps_item_send(
+                self.args.service,
+                self.args.node,
+                payload,
+                self.args.item,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't send item: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if published_id:
+                if self.args.quiet:
+                    self.disp(published_id, end="")
+                else:
+                    self.disp(f"Item published at {published_id}")
+            else:
+                self.disp("Item published")
+            self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_XML,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
+            help=_("get pubsub item(s)"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-S",
+            "--sub-id",
+            default="",
+            help=_("subscription id"),
+        )
+        self.parser.add_argument(
+            "--no-decrypt",
+            action="store_true",
+            help=_("don't do automatic decryption of e2ee items"),
+        )
+        #  TODO: a key(s) argument to select keys to display
+
+    async def start(self):
+        extra = {}
+        if self.args.no_decrypt:
+            extra["decrypt"] = False
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_items_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    self.args.sub_id,
+                    self.get_pubsub_extra(extra),
+                    self.profile,
+                )
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                self.disp(
+                    f"The node {self.args.node} doesn't exist on {self.args.service}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't get pubsub items: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            await self.output(ps_result["items"])
+            self.host.quit(C.EXIT_OK)
+
+
+class Delete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.ITEM, C.SINGLE_ITEM},
+            help=_("delete an item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true", help=_("delete without confirmation")
+        )
+        self.parser.add_argument(
+            "--no-notification", dest="notify", action="store_false",
+            help=_("do not send notification (not recommended)")
+        )
+
+    async def start(self):
+        if not self.args.item:
+            self.parser.error(_("You need to specify an item to delete"))
+        if not self.args.force:
+            message = _("Are you sure to delete item {item_id} ?").format(
+                item_id=self.args.item
+            )
+            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
+        try:
+            await self.host.bridge.ps_item_retract(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.notify,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't delete item: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("item {item} has been deleted").format(item=self.args.item))
+            self.host.quit(C.EXIT_OK)
+
+
+class Edit(base.CommandBase, common.BaseEdit):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            use_draft=True,
+            help=_("edit an existing or new pubsub item"),
+        )
+        common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the blog item")
+        )
+        self.parser.add_argument(
+            "--encrypt-for",
+            metavar="JID",
+            action="append",
+            help=_("encrypt a single item for")
+        )
+        self.parser.add_argument(
+            "-X",
+            "--sign",
+            action="store_true",
+            help=_("cryptographically sign the blog post")
+        )
+
+    async def publish(self, content):
+        extra = {}
+        if self.args.encrypt:
+            extra["encrypted"] = True
+        if self.args.encrypt_for:
+            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
+        if self.args.sign:
+            extra["signed"] = True
+        published_id = await self.host.bridge.ps_item_send(
+            self.pubsub_service,
+            self.pubsub_node,
+            content,
+            self.pubsub_item or "",
+            data_format.serialise(extra),
+            self.profile,
+        )
+        if published_id:
+            self.disp("Item published at {pub_id}".format(pub_id=published_id))
+        else:
+            self.disp("Item published")
+
+    async def get_item_data(self, service, node, item):
+        try:
+            from lxml import etree
+        except ImportError:
+            self.disp(
+                "lxml module must be installed to use edit, please install it "
+                'with "pip install lxml"',
+                error=True,
+            )
+            self.host.quit(1)
+        items = [item] if item else []
+        ps_result = data_format.deserialise(
+            await self.host.bridge.ps_items_get(
+                service, node, 1, items, "", data_format.serialise({}), self.profile
+            )
+        )
+        item_raw = ps_result["items"][0]
+        parser = etree.XMLParser(remove_blank_text=True, recover=True)
+        item_elt = etree.fromstring(item_raw, parser)
+        item_id = item_elt.get("id")
+        try:
+            payload = item_elt[0]
+        except IndexError:
+            self.disp(_("Item has not payload"), 1)
+            return "", item_id
+        return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id
+
+    async def start(self):
+        (
+            self.pubsub_service,
+            self.pubsub_node,
+            self.pubsub_item,
+            content_file_path,
+            content_file_obj,
+        ) = await self.get_item_path()
+        await self.run_editor("pubsub_editor_args", content_file_path, content_file_obj)
+        self.host.quit()
+
+
+class Rename(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "rename",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("rename a pubsub item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("new_id", help=_("new item id to use"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_item_rename(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.new_id,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't rename item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("Item renamed")
+            self.host.quit(C.EXIT_OK)
+
+
+class Subscribe(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "subscribe",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("subscribe to a node"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--public",
+            action="store_true",
+            help=_("make the registration visible for everybody"),
+        )
+
+    async def start(self):
+        options = {}
+        if self.args.public:
+            namespaces = await self.host.bridge.namespaces_get()
+            try:
+                ns_pps = namespaces["pps"]
+            except KeyError:
+                self.disp(
+                    "Pubsub Public Subscription plugin is not loaded, can't use --public "
+                    "option, subscription stopped", error=True
+                )
+                self.host.quit(C.EXIT_MISSING_FEATURE)
+            else:
+                options[f"{{{ns_pps}}}public"] = True
+        try:
+            sub_id = await self.host.bridge.ps_subscribe(
+                self.args.service,
+                self.args.node,
+                data_format.serialise(options),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't subscribe to node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("subscription done"), 1)
+            if sub_id:
+                self.disp(_("subscription id: {sub_id}").format(sub_id=sub_id))
+            self.host.quit()
+
+
+class Unsubscribe(base.CommandBase):
+    # FIXME: check why we get a a NodeNotFound on subscribe just after unsubscribe
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "unsubscribe",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("unsubscribe from a node"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_unsubscribe(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't unsubscribe from node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("subscription removed"), 1)
+            self.host.quit()
+
+
+class Subscriptions(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "subscriptions",
+            use_output=C.OUTPUT_LIST_DICT,
+            use_pubsub=True,
+            help=_("retrieve all subscriptions on a service"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--public",
+            action="store_true",
+            help=_("get public subscriptions"),
+        )
+
+    async def start(self):
+        if self.args.public:
+            method = self.host.bridge.ps_public_subscriptions_get
+        else:
+            method = self.host.bridge.ps_subscriptions_get
+        try:
+            subscriptions = data_format.deserialise(
+                await method(
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                ),
+                type_check=list
+            )
+        except Exception as e:
+            self.disp(_("can't retrieve subscriptions: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(subscriptions)
+            self.host.quit()
+
+
+class Affiliations(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "affiliations",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            help=_("retrieve all affiliations on a service"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            affiliations = await self.host.bridge.ps_affiliations_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get node affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(affiliations)
+            self.host.quit()
+
+
+class Reference(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "reference",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("send a reference/mention to pubsub item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            default="mention",
+            choices=("data", "mention"),
+            help=_("type of reference to send (DEFAULT: mention)"),
+        )
+        self.parser.add_argument(
+            "recipient",
+            help=_("recipient of the reference")
+        )
+
+    async def start(self):
+        service = self.args.service or await self.host.get_profile_jid()
+        if self.args.item:
+            anchor = uri.build_xmpp_uri(
+                "pubsub", path=service, node=self.args.node, item=self.args.item
+            )
+        else:
+            anchor = uri.build_xmpp_uri("pubsub", path=service, node=self.args.node)
+
+        try:
+            await self.host.bridge.reference_send(
+                self.args.recipient,
+                anchor,
+                self.args.type,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send reference: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Search(base.CommandBase):
+    """This command do a search without using MAM
+
+    This commands checks every items it finds by itself,
+    so it may be heavy in resources both for server and client
+    """
+
+    RE_FLAGS = re.MULTILINE | re.UNICODE
+    EXEC_ACTIONS = ("exec", "external")
+
+    def __init__(self, host):
+        # FIXME: C.NO_MAX is not needed here, and this can be globally removed from consts
+        #        the only interest is to change the help string, but this can be explained
+        #        extensively in man pages (max is for each node found)
+        base.CommandBase.__init__(
+            self,
+            host,
+            "search",
+            use_output=C.OUTPUT_XML,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS, C.NO_MAX},
+            use_verbose=True,
+            help=_("search items corresponding to filters"),
+        )
+
+    @property
+    def etree(self):
+        """load lxml.etree only if needed"""
+        if self._etree is None:
+            from lxml import etree
+
+            self._etree = etree
+        return self._etree
+
+    def filter_opt(self, value, type_):
+        return (type_, value)
+
+    def filter_flag(self, value, type_):
+        value = C.bool(value)
+        return (type_, value)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-D",
+            "--max-depth",
+            type=int,
+            default=0,
+            help=_(
+                "maximum depth of recursion (will search linked nodes if > 0, "
+                "DEFAULT: 0)"
+            ),
+        )
+        self.parser.add_argument(
+            "-M",
+            "--node-max",
+            type=int,
+            default=30,
+            help=_(
+                "maximum number of items to get per node ({} to get all items, "
+                "DEFAULT: 30)".format(C.NO_LIMIT)
+            ),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar="NAME NAMESPACE",
+            help=_("namespace to use for xpath"),
+        )
+
+        # filters
+        filter_text = partial(self.filter_opt, type_="text")
+        filter_re = partial(self.filter_opt, type_="regex")
+        filter_xpath = partial(self.filter_opt, type_="xpath")
+        filter_python = partial(self.filter_opt, type_="python")
+        filters = self.parser.add_argument_group(
+            _("filters"),
+            _("only items corresponding to following filters will be kept"),
+        )
+        filters.add_argument(
+            "-t",
+            "--text",
+            action="append",
+            dest="filters",
+            type=filter_text,
+            metavar="TEXT",
+            help=_("full text filter, item must contain this string (XML included)"),
+        )
+        filters.add_argument(
+            "-r",
+            "--regex",
+            action="append",
+            dest="filters",
+            type=filter_re,
+            metavar="EXPRESSION",
+            help=_("like --text but using a regular expression"),
+        )
+        filters.add_argument(
+            "-x",
+            "--xpath",
+            action="append",
+            dest="filters",
+            type=filter_xpath,
+            metavar="XPATH",
+            help=_("filter items which has elements matching this xpath"),
+        )
+        filters.add_argument(
+            "-P",
+            "--python",
+            action="append",
+            dest="filters",
+            type=filter_python,
+            metavar="PYTHON_CODE",
+            help=_(
+                "Python expression which much return a bool (True to keep item, "
+                'False to reject it). "item" is raw text item, "item_xml" is '
+                "lxml's etree.Element"
+            ),
+        )
+
+        # filters flags
+        flag_case = partial(self.filter_flag, type_="ignore-case")
+        flag_invert = partial(self.filter_flag, type_="invert")
+        flag_dotall = partial(self.filter_flag, type_="dotall")
+        flag_matching = partial(self.filter_flag, type_="only-matching")
+        flags = self.parser.add_argument_group(
+            _("filters flags"),
+            _("filters modifiers (change behaviour of following filters)"),
+        )
+        flags.add_argument(
+            "-C",
+            "--ignore-case",
+            action="append",
+            dest="filters",
+            type=flag_case,
+            const=("ignore-case", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("(don't) ignore case in following filters (DEFAULT: case sensitive)"),
+        )
+        flags.add_argument(
+            "-I",
+            "--invert",
+            action="append",
+            dest="filters",
+            type=flag_invert,
+            const=("invert", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("(don't) invert effect of following filters (DEFAULT: don't invert)"),
+        )
+        flags.add_argument(
+            "-A",
+            "--dot-all",
+            action="append",
+            dest="filters",
+            type=flag_dotall,
+            const=("dotall", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("(don't) use DOTALL option for regex (DEFAULT: don't use)"),
+        )
+        flags.add_argument(
+            "-k",
+            "--only-matching",
+            action="append",
+            dest="filters",
+            type=flag_matching,
+            const=("only-matching", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("keep only the matching part of the item"),
+        )
+
+        # action
+        self.parser.add_argument(
+            "action",
+            default="print",
+            nargs="?",
+            choices=("print", "exec", "external"),
+            help=_("action to do on found items (DEFAULT: print)"),
+        )
+        self.parser.add_argument("command", nargs=argparse.REMAINDER)
+
+    async def get_items(self, depth, service, node, items):
+        self.to_get += 1
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_items_get(
+                    service,
+                    node,
+                    self.args.node_max,
+                    items,
+                    "",
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(
+                f"can't get pubsub items at {service} (node: {node}): {e}",
+                error=True,
+            )
+            self.to_get -= 1
+        else:
+            await self.search(ps_result, depth)
+
+    def _check_pubsub_url(self, match, found_nodes):
+        """check that the matched URL is an xmpp: one
+
+        @param found_nodes(list[unicode]): found_nodes
+            this list will be filled while xmpp: URIs are discovered
+        """
+        url = match.group(0)
+        if url.startswith("xmpp"):
+            try:
+                url_data = uri.parse_xmpp_uri(url)
+            except ValueError:
+                return
+            if url_data["type"] == "pubsub":
+                found_node = {"service": url_data["path"], "node": url_data["node"]}
+                if "item" in url_data:
+                    found_node["item"] = url_data["item"]
+                found_nodes.append(found_node)
+
+    async def get_sub_nodes(self, item, depth):
+        """look for pubsub URIs in item, and get_items on the linked nodes"""
+        found_nodes = []
+        checkURI = partial(self._check_pubsub_url, found_nodes=found_nodes)
+        strings.RE_URL.sub(checkURI, item)
+        for data in found_nodes:
+            await self.get_items(
+                depth + 1,
+                data["service"],
+                data["node"],
+                [data["item"]] if "item" in data else [],
+            )
+
+    def parseXml(self, item):
+        try:
+            return self.etree.fromstring(item)
+        except self.etree.XMLSyntaxError:
+            self.disp(
+                _(
+                    "item doesn't looks like XML, you have probably used --only-matching "
+                    "somewhere before and we have no more XML"
+                ),
+                error=True,
+            )
+            self.host.quit(C.EXIT_BAD_ARG)
+
+    def filter(self, item):
+        """apply filters given on command line
+
+        if only-matching is used, item may be modified
+        @return (tuple[bool, unicode]): a tuple with:
+            - keep: True if item passed the filters
+            - item: it is returned in case of modifications
+        """
+        ignore_case = False
+        invert = False
+        dotall = False
+        only_matching = False
+        item_xml = None
+        for type_, value in self.args.filters:
+            keep = True
+
+            ## filters
+
+            if type_ == "text":
+                if ignore_case:
+                    if value.lower() not in item.lower():
+                        keep = False
+                else:
+                    if value not in item:
+                        keep = False
+                if keep and only_matching:
+                    # doesn't really make sens to keep a fixed string
+                    # so we raise an error
+                    self.host.disp(
+                        _("--only-matching used with fixed --text string, are you sure?"),
+                        error=True,
+                    )
+                    self.host.quit(C.EXIT_BAD_ARG)
+            elif type_ == "regex":
+                flags = self.RE_FLAGS
+                if ignore_case:
+                    flags |= re.IGNORECASE
+                if dotall:
+                    flags |= re.DOTALL
+                match = re.search(value, item, flags)
+                keep = match != None
+                if keep and only_matching:
+                    item = match.group()
+                    item_xml = None
+            elif type_ == "xpath":
+                if item_xml is None:
+                    item_xml = self.parseXml(item)
+                try:
+                    elts = item_xml.xpath(value, namespaces=self.args.namespace)
+                except self.etree.XPathEvalError as e:
+                    self.disp(_("can't use xpath: {reason}").format(reason=e), error=True)
+                    self.host.quit(C.EXIT_BAD_ARG)
+                keep = bool(elts)
+                if keep and only_matching:
+                    item_xml = elts[0]
+                    try:
+                        item = self.etree.tostring(item_xml, encoding="unicode")
+                    except TypeError:
+                        # we have a string only, not an element
+                        item = str(item_xml)
+                        item_xml = None
+            elif type_ == "python":
+                if item_xml is None:
+                    item_xml = self.parseXml(item)
+                cmd_ns = {"etree": self.etree, "item": item, "item_xml": item_xml}
+                try:
+                    keep = eval(value, cmd_ns)
+                except SyntaxError as e:
+                    self.disp(str(e), error=True)
+                    self.host.quit(C.EXIT_BAD_ARG)
+
+                    ## flags
+
+            elif type_ == "ignore-case":
+                ignore_case = value
+            elif type_ == "invert":
+                invert = value
+                #  we need to continue, else loop would end here
+                continue
+            elif type_ == "dotall":
+                dotall = value
+            elif type_ == "only-matching":
+                only_matching = value
+            else:
+                raise exceptions.InternalError(
+                    _("unknown filter type {type}").format(type=type_)
+                )
+
+            if invert:
+                keep = not keep
+            if not keep:
+                return False, item
+
+        return True, item
+
+    async def do_item_action(self, item, metadata):
+        """called when item has been kepts and the action need to be done
+
+        @param item(unicode): accepted item
+        """
+        action = self.args.action
+        if action == "print" or self.host.verbosity > 0:
+            try:
+                await self.output(item)
+            except self.etree.XMLSyntaxError:
+                # item is not valid XML, but a string
+                # can happen when --only-matching is used
+                self.disp(item)
+        if action in self.EXEC_ACTIONS:
+            item_elt = self.parseXml(item)
+            if action == "exec":
+                use = {
+                    "service": metadata["service"],
+                    "node": metadata["node"],
+                    "item": item_elt.get("id"),
+                    "profile": self.profile,
+                }
+                # we need to send a copy of self.args.command
+                # else it would be modified
+                parser_args, use_args = arg_tools.get_use_args(
+                    self.host, self.args.command, use, verbose=self.host.verbosity > 1
+                )
+                cmd_args = sys.argv[0:1] + parser_args + use_args
+            else:
+                cmd_args = self.args.command
+
+            self.disp(
+                "COMMAND: {command}".format(
+                    command=" ".join([arg_tools.escape(a) for a in cmd_args])
+                ),
+                2,
+            )
+            if action == "exec":
+                p = await asyncio.create_subprocess_exec(*cmd_args)
+                ret = await p.wait()
+            else:
+                p = await asyncio.create_subprocess_exec(*cmd_args, stdin=subprocess.PIPE)
+                await p.communicate(item.encode(sys.getfilesystemencoding()))
+                ret = p.returncode
+            if ret != 0:
+                self.disp(
+                    A.color(
+                        C.A_FAILURE,
+                        _("executed command failed with exit code {ret}").format(ret=ret),
+                    )
+                )
+
+    async def search(self, ps_result, depth):
+        """callback of get_items
+
+        this method filters items, get sub nodes if needed,
+        do the requested action, and exit the command when everything is done
+        @param items_data(tuple): result of get_items
+        @param depth(int): current depth level
+            0 for first node, 1 for first children, and so on
+        """
+        for item in ps_result["items"]:
+            if depth < self.args.max_depth:
+                await self.get_sub_nodes(item, depth)
+            keep, item = self.filter(item)
+            if not keep:
+                continue
+            await self.do_item_action(item, ps_result)
+
+            #  we check if we got all get_items results
+        self.to_get -= 1
+        if self.to_get == 0:
+            # yes, we can quit
+            self.host.quit()
+        assert self.to_get > 0
+
+    async def start(self):
+        if self.args.command:
+            if self.args.action not in self.EXEC_ACTIONS:
+                self.parser.error(
+                    _("Command can only be used with {actions} actions").format(
+                        actions=", ".join(self.EXEC_ACTIONS)
+                    )
+                )
+        else:
+            if self.args.action in self.EXEC_ACTIONS:
+                self.parser.error(_("you need to specify a command to execute"))
+        if not self.args.node:
+            # TODO: handle get service affiliations when node is not set
+            self.parser.error(_("empty node is not handled yet"))
+            # to_get is increased on each get and decreased on each answer
+            # when it reach 0 again, the command is finished
+        self.to_get = 0
+        self._etree = None
+        if self.args.filters is None:
+            self.args.filters = []
+        self.args.namespace = dict(
+            self.args.namespace + [("pubsub", "http://jabber.org/protocol/pubsub")]
+        )
+        await self.get_items(0, self.args.service, self.args.node, self.args.items)
+
+
+class Transform(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "transform",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.MULTI_ITEMS},
+            help=_("modify items of a node using an external command/script"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--apply",
+            action="store_true",
+            help=_("apply transformation (DEFAULT: do a dry run)"),
+        )
+        self.parser.add_argument(
+            "--admin",
+            action="store_true",
+            help=_("do a pubsub admin request, needed to change publisher"),
+        )
+        self.parser.add_argument(
+            "-I",
+            "--ignore-errors",
+            action="store_true",
+            help=_(
+                "if command return a non zero exit code, ignore the item and continue"
+            ),
+        )
+        self.parser.add_argument(
+            "-A",
+            "--all",
+            action="store_true",
+            help=_("get all items by looping over all pages using RSM"),
+        )
+        self.parser.add_argument(
+            "command_path",
+            help=_(
+                "path to the command to use. Will be called repetitivly with an "
+                "item as input. Output (full item XML) will be used as new one. "
+                'Return "DELETE" string to delete the item, and "SKIP" to ignore it'
+            ),
+        )
+
+    async def ps_items_send_cb(self, item_ids, metadata):
+        if item_ids:
+            self.disp(
+                _("items published with ids {item_ids}").format(
+                    item_ids=", ".join(item_ids)
+                )
+            )
+        else:
+            self.disp(_("items published"))
+        if self.args.all:
+            return await self.handle_next_page(metadata)
+        else:
+            self.host.quit()
+
+    async def handle_next_page(self, metadata):
+        """Retrieve new page through RSM or quit if we're in the last page
+
+        use to handle --all option
+        @param metadata(dict): metadata as returned by ps_items_get
+        """
+        try:
+            last = metadata["rsm"]["last"]
+            index = int(metadata["rsm"]["index"])
+            count = int(metadata["rsm"]["count"])
+        except KeyError:
+            self.disp(
+                _("Can't retrieve all items, RSM metadata not available"), error=True
+            )
+            self.host.quit(C.EXIT_MISSING_FEATURE)
+        except ValueError as e:
+            self.disp(
+                _("Can't retrieve all items, bad RSM metadata: {msg}").format(msg=e),
+                error=True,
+            )
+            self.host.quit(C.EXIT_ERROR)
+
+        if index + self.args.rsm_max >= count:
+            self.disp(_("All items transformed"))
+            self.host.quit(0)
+
+        self.disp(
+            _("Retrieving next page ({page_idx}/{page_total})").format(
+                page_idx=int(index / self.args.rsm_max) + 1,
+                page_total=int(count / self.args.rsm_max),
+            )
+        )
+
+        extra = self.get_pubsub_extra()
+        extra["rsm_after"] = last
+        try:
+            ps_result = await data_format.deserialise(
+                self.host.bridge.ps_items_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.rsm_max,
+                    self.args.items,
+                    "",
+                    data_format.serialise(extra),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.ps_items_get_cb(ps_result)
+
+    async def ps_items_get_cb(self, ps_result):
+        encoding = "utf-8"
+        new_items = []
+
+        for item in ps_result["items"]:
+            if self.check_duplicates:
+                # this is used when we are not ordering by creation
+                # to avoid infinite loop
+                item_elt, __ = xml_tools.etree_parse(self, item)
+                item_id = item_elt.get("id")
+                if item_id in self.items_ids:
+                    self.disp(
+                        _(
+                            "Duplicate found on item {item_id}, we have probably handled "
+                            "all items."
+                        ).format(item_id=item_id)
+                    )
+                    self.host.quit()
+                self.items_ids.append(item_id)
+
+                # we launch the command to filter the item
+            try:
+                p = await asyncio.create_subprocess_exec(
+                    self.args.command_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE
+                )
+            except OSError as e:
+                exit_code = C.EXIT_CMD_NOT_FOUND if e.errno == 2 else C.EXIT_ERROR
+                self.disp(f"Can't execute the command: {e}", error=True)
+                self.host.quit(exit_code)
+            encoding = "utf-8"
+            cmd_std_out, cmd_std_err = await p.communicate(item.encode(encoding))
+            ret = p.returncode
+            if ret != 0:
+                self.disp(
+                    f"The command returned a non zero status while parsing the "
+                    f"following item:\n\n{item}",
+                    error=True,
+                )
+                if self.args.ignore_errors:
+                    continue
+                else:
+                    self.host.quit(C.EXIT_CMD_ERROR)
+            if cmd_std_err is not None:
+                cmd_std_err = cmd_std_err.decode(encoding, errors="ignore")
+                self.disp(cmd_std_err, error=True)
+            cmd_std_out = cmd_std_out.decode(encoding).strip()
+            if cmd_std_out == "DELETE":
+                item_elt, __ = xml_tools.etree_parse(self, item)
+                item_id = item_elt.get("id")
+                self.disp(_("Deleting item {item_id}").format(item_id=item_id))
+                if self.args.apply:
+                    try:
+                        await self.host.bridge.ps_item_retract(
+                            self.args.service,
+                            self.args.node,
+                            item_id,
+                            False,
+                            self.profile,
+                        )
+                    except Exception as e:
+                        self.disp(f"can't delete item {item_id}: {e}", error=True)
+                        self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                continue
+            elif cmd_std_out == "SKIP":
+                item_elt, __ = xml_tools.etree_parse(self, item)
+                item_id = item_elt.get("id")
+                self.disp(_("Skipping item {item_id}").format(item_id=item_id))
+                continue
+            element, etree = xml_tools.etree_parse(self, cmd_std_out)
+
+            # at this point command has been run and we have a etree.Element object
+            if element.tag not in ("item", "{http://jabber.org/protocol/pubsub}item"):
+                self.disp(
+                    "your script must return a whole item, this is not:\n{xml}".format(
+                        xml=etree.tostring(element, encoding="unicode")
+                    ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_DATA_ERROR)
+
+            if not self.args.apply:
+                # we have a dry run, we just display filtered items
+                serialised = etree.tostring(
+                    element, encoding="unicode", pretty_print=True
+                )
+                self.disp(serialised)
+            else:
+                new_items.append(etree.tostring(element, encoding="unicode"))
+
+        if not self.args.apply:
+            # on dry run we have nothing to wait for, we can quit
+            if self.args.all:
+                return await self.handle_next_page(ps_result)
+            self.host.quit()
+        else:
+            if self.args.admin:
+                bridge_method = self.host.bridge.ps_admin_items_send
+            else:
+                bridge_method = self.host.bridge.ps_items_send
+
+            try:
+                ps_items_send_result = await bridge_method(
+                    self.args.service,
+                    self.args.node,
+                    new_items,
+                    "",
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(f"can't send item: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                await self.ps_items_send_cb(ps_items_send_result, metadata=ps_result)
+
+    async def start(self):
+        if self.args.all and self.args.order_by != C.ORDER_BY_CREATION:
+            self.check_duplicates = True
+            self.items_ids = []
+            self.disp(
+                A.color(
+                    A.FG_RED,
+                    A.BOLD,
+                    '/!\\ "--all" should be used with "--order-by creation" /!\\\n',
+                    A.RESET,
+                    "We'll update items, so order may change during transformation,\n"
+                    "we'll try to mitigate that by stopping on first duplicate,\n"
+                    "but this method is not safe, and some items may be missed.\n---\n",
+                )
+            )
+        else:
+            self.check_duplicates = False
+
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_items_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    "",
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.ps_items_get_cb(ps_result)
+
+
+class Uri(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "uri",
+            use_profile=False,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("build URI"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default=C.PROF_KEY_DEFAULT,
+            help=_("profile (used when no server is specified)"),
+        )
+
+    def display_uri(self, jid_):
+        uri_args = {}
+        if not self.args.service:
+            self.args.service = jid.JID(jid_).bare
+
+        for key in ("node", "service", "item"):
+            value = getattr(self.args, key)
+            if key == "service":
+                key = "path"
+            if value:
+                uri_args[key] = value
+        self.disp(uri.build_xmpp_uri("pubsub", **uri_args))
+        self.host.quit()
+
+    async def start(self):
+        if not self.args.service:
+            try:
+                jid_ = await self.host.bridge.param_get_a_async(
+                    "JabberID", "Connection", profile_key=self.args.profile
+                )
+            except Exception as e:
+                self.disp(f"can't retrieve jid: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                self.display_uri(jid_)
+        else:
+            self.display_uri(None)
+
+
+class AttachmentGet(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
+            help=_("get data attached to an item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            action="append",
+            dest="jids",
+            help=_(
+                "get attached data published only by those JIDs (DEFAULT: get all "
+                "attached data)"
+            )
+        )
+
+    async def start(self):
+        try:
+            attached_data, __ = await self.host.bridge.ps_attachments_get(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.jids or [],
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get attached data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            attached_data = data_format.deserialise(attached_data, type_check=list)
+            await self.output(attached_data)
+            self.host.quit(C.EXIT_OK)
+
+
+class AttachmentSet(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
+            help=_("attach data to an item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--replace",
+            action="store_true",
+            help=_(
+                "replace previous versions of attachments (DEFAULT: update previous "
+                "version)"
+            )
+        )
+        self.parser.add_argument(
+            "-N",
+            "--noticed",
+            metavar="BOOLEAN",
+            nargs="?",
+            default="keep",
+            help=_("mark item as (un)noticed (DEFAULT: keep current value))")
+        )
+        self.parser.add_argument(
+            "-r",
+            "--reactions",
+            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
+            action="append",
+            help=_("emojis to add to react to an item")
+        )
+        self.parser.add_argument(
+            "-R",
+            "--reactions-remove",
+            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
+            action="append",
+            help=_("emojis to remove from reactions to an item")
+        )
+
+    async def start(self):
+        attachments_data = {
+            "service": self.args.service,
+            "node": self.args.node,
+            "id": self.args.item,
+            "extra": {}
+        }
+        operation = "replace" if self.args.replace else "update"
+        if self.args.noticed != "keep":
+            if self.args.noticed is None:
+                self.args.noticed = C.BOOL_TRUE
+            attachments_data["extra"]["noticed"] = C.bool(self.args.noticed)
+
+        if self.args.reactions or self.args.reactions_remove:
+            reactions = attachments_data["extra"]["reactions"] = {
+                "operation": operation
+            }
+            if self.args.replace:
+                reactions["reactions"] = self.args.reactions
+            else:
+                reactions["add"] = self.args.reactions
+                reactions["remove"] = self.args.reactions_remove
+
+
+        if not attachments_data["extra"]:
+            self.parser.error(_("At leat one attachment must be specified."))
+
+        try:
+            await self.host.bridge.ps_attachments_set(
+                data_format.serialise(attachments_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't attach data to item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("data attached")
+            self.host.quit(C.EXIT_OK)
+
+
+class Attachments(base.CommandBase):
+    subcommands = (AttachmentGet, AttachmentSet)
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "attachments",
+            use_profile=False,
+            help=_("set or retrieve items attachments"),
+        )
+
+
+class SignatureSign(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "sign",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("sign an item"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        attachments_data = {
+            "service": self.args.service,
+            "node": self.args.node,
+            "id": self.args.item,
+            "extra": {
+                # we set None to use profile's bare JID
+                "signature": {"signer": None}
+            }
+        }
+        try:
+            await self.host.bridge.ps_attachments_set(
+                data_format.serialise(attachments_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't sign the item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(f"item {self.args.item!r} has been signed")
+            self.host.quit(C.EXIT_OK)
+
+
+class SignatureCheck(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "check",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
+            help=_("check the validity of pubsub signature"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "signature",
+            metavar="JSON",
+            help=_("signature data")
+        )
+
+    async def start(self):
+        try:
+            ret_s = await self.host.bridge.ps_signature_check(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.signature,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't check signature: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(data_format.deserialise((ret_s)))
+            self.host.quit()
+
+
+class Signature(base.CommandBase):
+    subcommands = (
+        SignatureSign,
+        SignatureCheck,
+    )
+
+    def __init__(self, host):
+        super().__init__(
+            host, "signature", use_profile=False, help=_("items signatures")
+        )
+
+
+class SecretShare(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "share",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("share a secret to let other entity encrypt or decrypt items"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k", "--key", metavar="ID", dest="secret_ids", action="append", default=[],
+            help=_(
+                "only share secrets with those IDs (default: share all secrets of the "
+                "node)"
+            )
+        )
+        self.parser.add_argument(
+            "recipient", metavar="JID", help=_("entity who must get the shared secret")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_secret_share(
+                self.args.recipient,
+                self.args.service,
+                self.args.node,
+                self.args.secret_ids,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't share secret: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("secrets have been shared")
+            self.host.quit(C.EXIT_OK)
+
+
+class SecretRevoke(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "revoke",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("revoke an encrypted node secret"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "secret_id", help=_("ID of the secrets to revoke")
+        )
+        self.parser.add_argument(
+            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
+            default=[], help=_(
+                "entity who must get the revocation notification (default: send to all "
+                "entities known to have the shared secret)"
+            )
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_secret_revoke(
+                self.args.service,
+                self.args.node,
+                self.args.secret_id,
+                self.args.recipients,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't revoke secret: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("secret {self.args.secret_id} has been revoked.")
+            self.host.quit(C.EXIT_OK)
+
+
+class SecretRotate(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "rotate",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("revoke existing secrets, create a new one and send notifications"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
+            default=[], help=_(
+                "entity who must get the revocation and shared secret notifications "
+                "(default: send to all entities known to have the shared secret)"
+            )
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_secret_rotate(
+                self.args.service,
+                self.args.node,
+                self.args.recipients,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't rotate secret: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("secret has been rotated")
+            self.host.quit(C.EXIT_OK)
+
+
+class SecretList(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "list",
+            use_pubsub=True,
+            use_verbose=True,
+            pubsub_flags={C.NODE},
+            help=_("list known secrets for a pubsub node"),
+            use_output=C.OUTPUT_LIST_DICT
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            secrets = data_format.deserialise(await self.host.bridge.ps_secrets_list(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            ), type_check=list)
+        except Exception as e:
+            self.disp(f"can't list node secrets: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not self.verbosity:
+                # we don't print key if verbosity is not a least one, to avoid showing it
+                # on the screen accidentally
+                for secret in secrets:
+                    del secret["key"]
+            await self.output(secrets)
+            self.host.quit(C.EXIT_OK)
+
+
+class Secret(base.CommandBase):
+    subcommands = (SecretShare, SecretRevoke, SecretRotate, SecretList)
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "secret",
+            use_profile=False,
+            help=_("handle encrypted nodes secrets"),
+        )
+
+
+class HookCreate(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "create",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("create a Pubsub hook"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            default="python",
+            choices=("python", "python_file", "python_code"),
+            help=_("hook type"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--persistent",
+            action="store_true",
+            help=_("make hook persistent across restarts"),
+        )
+        self.parser.add_argument(
+            "hook_arg",
+            help=_("argument of the hook (depend of the type)"),
+        )
+
+    @staticmethod
+    def check_args(self):
+        if self.args.type == "python_file":
+            self.args.hook_arg = os.path.abspath(self.args.hook_arg)
+            if not os.path.isfile(self.args.hook_arg):
+                self.parser.error(
+                    _("{path} is not a file").format(path=self.args.hook_arg)
+                )
+
+    async def start(self):
+        self.check_args(self)
+        try:
+            await self.host.bridge.ps_hook_add(
+                self.args.service,
+                self.args.node,
+                self.args.type,
+                self.args.hook_arg,
+                self.args.persistent,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create hook: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class HookDelete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("delete a Pubsub hook"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            default="",
+            choices=("", "python", "python_file", "python_code"),
+            help=_("hook type to remove, empty to remove all (DEFAULT: remove all)"),
+        )
+        self.parser.add_argument(
+            "-a",
+            "--arg",
+            dest="hook_arg",
+            default="",
+            help=_(
+                "argument of the hook to remove, empty to remove all (DEFAULT: remove all)"
+            ),
+        )
+
+    async def start(self):
+        HookCreate.check_args(self)
+        try:
+            nb_deleted = await self.host.bridge.ps_hook_remove(
+                self.args.service,
+                self.args.node,
+                self.args.type,
+                self.args.hook_arg,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't delete hook: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _("{nb_deleted} hook(s) have been deleted").format(nb_deleted=nb_deleted)
+            )
+            self.host.quit()
+
+
+class HookList(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "list",
+            use_output=C.OUTPUT_LIST_DICT,
+            help=_("list hooks of a profile"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.ps_hook_list(
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't list hooks: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not data:
+                self.disp(_("No hook found."))
+            await self.output(data)
+            self.host.quit()
+
+
+class Hook(base.CommandBase):
+    subcommands = (HookCreate, HookDelete, HookList)
+
+    def __init__(self, host):
+        super(Hook, self).__init__(
+            host,
+            "hook",
+            use_profile=False,
+            use_verbose=True,
+            help=_("trigger action on Pubsub notifications"),
+        )
+
+
+class Pubsub(base.CommandBase):
+    subcommands = (
+        Set,
+        Get,
+        Delete,
+        Edit,
+        Rename,
+        Subscribe,
+        Unsubscribe,
+        Subscriptions,
+        Affiliations,
+        Reference,
+        Search,
+        Transform,
+        Attachments,
+        Signature,
+        Secret,
+        Hook,
+        Uri,
+        Node,
+        Cache,
+    )
+
+    def __init__(self, host):
+        super(Pubsub, self).__init__(
+            host, "pubsub", use_profile=False, help=_("PubSub nodes/items management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_roster.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2003-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from . import base
+from collections import OrderedDict
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common.ansi import ANSI as A
+
+__commands__ = ["Roster"]
+
+
+class Get(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host, 'get', use_output=C.OUTPUT_DICT, use_verbose=True,
+            extra_outputs = {"default": self.default_output},
+            help=_('retrieve the roster entities'))
+
+    def add_parser_options(self):
+        pass
+
+    def default_output(self, data):
+        for contact_jid, contact_data in data.items():
+            all_keys = list(contact_data.keys())
+            keys_to_show = []
+            name = contact_data.get('name', contact_jid.node)
+
+            if self.verbosity >= 1:
+                keys_to_show.append('groups')
+                all_keys.remove('groups')
+            if self.verbosity >= 2:
+                keys_to_show.extend(all_keys)
+
+            if name is None:
+                self.disp(A.color(C.A_HEADER, contact_jid))
+            else:
+                self.disp(A.color(C.A_HEADER, name, A.RESET, f" ({contact_jid})"))
+            for k in keys_to_show:
+                value = contact_data[k]
+                if value:
+                    if isinstance(value, list):
+                        value = ', '.join(value)
+                    self.disp(A.color(
+                        "    ", C.A_SUBHEADER, f"{k}: ", A.RESET, str(value)))
+
+    async def start(self):
+        try:
+            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
+        except Exception as e:
+            self.disp(f"error while retrieving the contacts: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        contacts_dict = {}
+        for contact_jid_s, data, groups in contacts:
+            # FIXME: we have to convert string to bool here for historical reason
+            #        contacts_get format should be changed and serialised properly
+            for key in ('from', 'to', 'ask'):
+                if key in data:
+                    data[key] = C.bool(data[key])
+            data['groups'] = list(groups)
+            contacts_dict[jid.JID(contact_jid_s)] = data
+
+        await self.output(contacts_dict)
+        self.host.quit()
+
+
+class Set(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(host, 'set', help=_('set metadata for a roster entity'))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n", "--name", default="", help=_('name to use for this entity'))
+        self.parser.add_argument(
+            "-g", "--group", dest='groups', action='append', metavar='GROUP', default=[],
+            help=_('groups for this entity'))
+        self.parser.add_argument(
+            "-R", "--replace", action="store_true",
+            help=_("replace all metadata instead of adding them"))
+        self.parser.add_argument(
+            "jid", help=_("jid of the roster entity"))
+
+    async def start(self):
+
+        if self.args.replace:
+            name = self.args.name
+            groups = self.args.groups
+        else:
+            try:
+                entity_data = await self.host.bridge.contact_get(
+                    self.args.jid, self.host.profile)
+            except Exception as e:
+                self.disp(f"error while retrieving the contact: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            name = self.args.name or entity_data[0].get('name') or ''
+            groups = set(entity_data[1])
+            groups = list(groups.union(self.args.groups))
+
+        try:
+            await self.host.bridge.contact_update(
+                self.args.jid, name, groups, self.host.profile)
+        except Exception as e:
+            self.disp(f"error while updating the contact: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        self.host.quit()
+
+
+class Delete(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(host, 'delete', help=_('remove an entity from roster'))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true", help=_("delete without confirmation")
+        )
+        self.parser.add_argument(
+            "jid", help=_("jid of the roster entity"))
+
+    async def start(self):
+        if not self.args.force:
+            message = _("Are you sure to delete {entity} from your roster?").format(
+                entity=self.args.jid
+            )
+            await self.host.confirm_or_quit(message, _("entity deletion cancelled"))
+        try:
+            await self.host.bridge.contact_del(
+                self.args.jid, self.host.profile)
+        except Exception as e:
+            self.disp(f"error while deleting the entity: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        self.host.quit()
+
+
+class Stats(base.CommandBase):
+
+    def __init__(self, host):
+        super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
+        except Exception as e:
+            self.disp(f"error while retrieving the contacts: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        hosts = {}
+        unique_groups = set()
+        no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0
+        for contact, attrs, groups in contacts:
+            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
+            if not from_:
+                if not to:
+                    no_sub += 1
+                else:
+                    no_from += 1
+            elif not to:
+                no_to += 1
+
+            host = jid.JID(contact).domain
+
+            hosts.setdefault(host, 0)
+            hosts[host] += 1
+            if groups:
+                unique_groups.update(groups)
+                total_group_subscription += len(groups)
+            if not groups:
+                no_group += 1
+        hosts = OrderedDict(sorted(list(hosts.items()), key=lambda item:-item[1]))
+
+        print()
+        print("Total number of contacts: %d" % len(contacts))
+        print("Number of different hosts: %d" % len(hosts))
+        print()
+        for host, count in hosts.items():
+            print("Contacts on {host}: {count} ({rate:.1f}%)".format(
+                host=host, count=count, rate=100 * float(count) / len(contacts)))
+        print()
+        print("Contacts with no 'from' subscription: %d" % no_from)
+        print("Contacts with no 'to' subscription: %d" % no_to)
+        print("Contacts with no subscription at all: %d" % no_sub)
+        print()
+        print("Total number of groups: %d" % len(unique_groups))
+        try:
+            contacts_per_group = float(total_group_subscription) / len(unique_groups)
+        except ZeroDivisionError:
+            contacts_per_group = 0
+        print("Average contacts per group: {:.1f}".format(contacts_per_group))
+        try:
+            groups_per_contact = float(total_group_subscription) / len(contacts)
+        except ZeroDivisionError:
+            groups_per_contact = 0
+        print(f"Average groups' subscriptions per contact: {groups_per_contact:.1f}")
+        print("Contacts not assigned to any group: %d" % no_group)
+        self.host.quit()
+
+
+class Purge(base.CommandBase):
+
+    def __init__(self, host):
+        super(Purge, self).__init__(
+            host, 'purge',
+            help=_('purge the roster from its contacts with no subscription'))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--no-from", action="store_true",
+            help=_("also purge contacts with no 'from' subscription"))
+        self.parser.add_argument(
+            "--no-to", action="store_true",
+            help=_("also purge contacts with no 'to' subscription"))
+
+    async def start(self):
+        try:
+            contacts = await self.host.bridge.contacts_get(self.host.profile)
+        except Exception as e:
+            self.disp(f"error while retrieving the contacts: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        no_sub, no_from, no_to = [], [], []
+        for contact, attrs, groups in contacts:
+            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
+            if not from_:
+                if not to:
+                    no_sub.append(contact)
+                elif self.args.no_from:
+                    no_from.append(contact)
+            elif not to and self.args.no_to:
+                no_to.append(contact)
+        if not no_sub and not no_from and not no_to:
+            self.disp(
+                f"Nothing to do - there's a from and/or to subscription(s) between "
+                f"profile {self.host.profile!r} and each of its contacts"
+            )
+        elif await self.ask_confirmation(no_sub, no_from, no_to):
+            for contact in no_sub + no_from + no_to:
+                try:
+                    await self.host.bridge.contact_del(
+                        contact, profile_key=self.host.profile)
+                except Exception as e:
+                    self.disp(f"can't delete contact {contact!r}: {e}", error=True)
+                else:
+                    self.disp(f"contact {contact!r} has been removed")
+
+        self.host.quit()
+
+    async def ask_confirmation(self, no_sub, no_from, no_to):
+        """Ask the confirmation before removing contacts.
+
+        @param no_sub (list[unicode]): list of contacts with no subscription
+        @param no_from (list[unicode]): list of contacts with no 'from' subscription
+        @param no_to (list[unicode]): list of contacts with no 'to' subscription
+        @return bool
+        """
+        if no_sub:
+            self.disp(
+                f"There's no subscription between profile {self.host.profile!r} and the "
+                f"following contacts:")
+            self.disp("    " + "\n    ".join(no_sub))
+        if no_from:
+            self.disp(
+                f"There's no 'from' subscription between profile {self.host.profile!r} "
+                f"and the following contacts:")
+            self.disp("    " + "\n    ".join(no_from))
+        if no_to:
+            self.disp(
+                f"There's no 'to' subscription between profile {self.host.profile!r} and "
+                f"the following contacts:")
+            self.disp("    " + "\n    ".join(no_to))
+        message = f"REMOVE them from profile {self.host.profile}'s roster"
+        while True:
+            res = await self.host.ainput(f"{message} (y/N)? ")
+            if not res or res.lower() == 'n':
+                return False
+            if res.lower() == 'y':
+                return True
+
+
+class Resync(base.CommandBase):
+
+    def __init__(self, host):
+        super(Resync, self).__init__(
+            host, 'resync', help=_('do a full resynchronisation of roster with server'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.roster_resync(profile_key=self.host.profile)
+        except Exception as e:
+            self.disp(f"can't resynchronise roster: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("Roster resynchronized"))
+            self.host.quit(C.EXIT_OK)
+
+
+class Roster(base.CommandBase):
+    subcommands = (Get, Set, Delete, Stats, Purge, Resync)
+
+    def __init__(self, host):
+        super(Roster, self).__init__(
+            host, 'roster', use_profile=True, help=_("Manage an entity's roster"))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_shell.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import cmd
+import sys
+import shlex
+import subprocess
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.cli.constants import Const as C
+from libervia.cli import arg_tools
+from libervia.backend.tools.common.ansi import ANSI as A
+
+__commands__ = ["Shell"]
+INTRO = _(
+    """Welcome to {app_name} shell, the Salut à Toi shell !
+
+This enrironment helps you using several {app_name} commands with similar parameters.
+
+To quit, just enter "quit" or press C-d.
+Enter "help" or "?" to know what to do
+"""
+).format(app_name=C.APP_NAME)
+
+
+class Shell(base.CommandBase, cmd.Cmd):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "shell",
+            help=_("launch libervia-cli in shell (REPL) mode")
+        )
+        cmd.Cmd.__init__(self)
+
+    def parse_args(self, args):
+        """parse line arguments"""
+        return shlex.split(args, posix=True)
+
+    def update_path(self):
+        self._cur_parser = self.host.parser
+        self.help = ""
+        for idx, path_elt in enumerate(self.path):
+            try:
+                self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser)
+            except exceptions.NotFound:
+                self.disp(_("bad command path"), error=True)
+                self.path = self.path[:idx]
+                break
+            else:
+                self.help = self._cur_parser
+
+        self.prompt = A.color(C.A_PROMPT_PATH, "/".join(self.path)) + A.color(
+            C.A_PROMPT_SUF, "> "
+        )
+        try:
+            self.actions = list(arg_tools.get_cmd_choices(parser=self._cur_parser).keys())
+        except exceptions.NotFound:
+            self.actions = []
+
+    def add_parser_options(self):
+        pass
+
+    def format_args(self, args):
+        """format argument to be printed with quotes if needed"""
+        for arg in args:
+            if " " in arg:
+                yield arg_tools.escape(arg)
+            else:
+                yield arg
+
+    def run_cmd(self, args, external=False):
+        """run command and retur exit code
+
+        @param args[list[string]]: arguments of the command
+            must not include program name
+        @param external(bool): True if it's an external command (i.e. not libervia-cli)
+        @return (int): exit code (0 success, any other int failure)
+        """
+        # FIXME: we have to use subprocess
+        # and relaunch whole python for now
+        # because if host.quit() is called in D-Bus callback
+        # GLib quit the whole app without possibility to stop it
+        # didn't found a nice way to work around it so far
+        # Situation should be better when we'll move away from python-dbus
+        if self.verbose:
+            self.disp(
+                _("COMMAND {external}=> {args}").format(
+                    external=_("(external) ") if external else "",
+                    args=" ".join(self.format_args(args)),
+                )
+            )
+        if not external:
+            args = sys.argv[0:1] + args
+        ret_code = subprocess.call(args)
+        # XXX: below is a way to launch the command without creating a new process
+        #      may be used when a solution to the aforementioned issue is there
+        # try:
+        #     self.host._run(args)
+        # except SystemExit as e:
+        #     ret_code = e.code
+        # except Exception as e:
+        #     self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True)
+        #     ret_code = 1
+        # else:
+        #     ret_code = 0
+
+        if ret_code != 0:
+            self.disp(
+                A.color(
+                    C.A_FAILURE,
+                    "command failed with an error code of {err_no}".format(
+                        err_no=ret_code
+                    ),
+                ),
+                error=True,
+            )
+        return ret_code
+
+    def default(self, args):
+        """called when no shell command is recognized
+
+        will launch the command with args on the line
+        (i.e. will launch do [args])
+        """
+        if args == "EOF":
+            self.do_quit("")
+        self.do_do(args)
+
+    def do_help(self, args):
+        """show help message"""
+        if not args:
+            self.disp(A.color(C.A_HEADER, _("Shell commands:")), end=' ')
+        super(Shell, self).do_help(args)
+        if not args:
+            self.disp(A.color(C.A_HEADER, _("Action commands:")))
+            help_list = self._cur_parser.format_help().split("\n\n")
+            print(("\n\n".join(help_list[1 if self.path else 2 :])))
+
+    # FIXME: debug crashes on exit and is not that useful,
+    #        keeping it until refactoring, may be removed entirely then
+    # def do_debug(self, args):
+    #     """launch internal debugger"""
+    #     try:
+    #         import ipdb as pdb
+    #     except ImportError:
+    #         import pdb
+    #     pdb.set_trace()
+
+    def do_verbose(self, args):
+        """show verbose mode, or (de)activate it"""
+        args = self.parse_args(args)
+        if args:
+            self.verbose = C.bool(args[0])
+        self.disp(
+            _("verbose mode is {status}").format(
+                status=_("ENABLED") if self.verbose else _("DISABLED")
+            )
+        )
+
+    def do_cmd(self, args):
+        """change command path"""
+        if args == "..":
+            self.path = self.path[:-1]
+        else:
+            if not args or args[0] == "/":
+                self.path = []
+            args = "/".join(args.split())
+            for path_elt in args.split("/"):
+                path_elt = path_elt.strip()
+                if not path_elt:
+                    continue
+                self.path.append(path_elt)
+        self.update_path()
+
+    def do_version(self, args):
+        """show current backend/CLI version"""
+        self.run_cmd(['--version'])
+
+    def do_shell(self, args):
+        """launch an external command (you can use ![command] too)"""
+        args = self.parse_args(args)
+        self.run_cmd(args, external=True)
+
+    def do_do(self, args):
+        """lauch a command"""
+        args = self.parse_args(args)
+        if (
+            self._not_default_profile
+            and not "-p" in args
+            and not "--profile" in args
+            and not "profile" in self.use
+        ):
+            # profile is not specified and we are not using the default profile
+            # so we need to add it in arguments to use current user profile
+            if self.verbose:
+                self.disp(
+                    _("arg profile={profile} (logged profile)").format(
+                        profile=self.profile
+                    )
+                )
+            use = self.use.copy()
+            use["profile"] = self.profile
+        else:
+            use = self.use
+
+        # args may be modified by use_args
+        # to remove subparsers from it
+        parser_args, use_args = arg_tools.get_use_args(
+            self.host, args, use, verbose=self.verbose, parser=self._cur_parser
+        )
+        cmd_args = self.path + parser_args + use_args
+        self.run_cmd(cmd_args)
+
+    def do_use(self, args):
+        """fix an argument"""
+        args = self.parse_args(args)
+        if not args:
+            if not self.use:
+                self.disp(_("no argument in USE"))
+            else:
+                self.disp(_("arguments in USE:"))
+                for arg, value in self.use.items():
+                    self.disp(
+                        _(
+                            A.color(
+                                C.A_SUBHEADER,
+                                arg,
+                                A.RESET,
+                                " = ",
+                                arg_tools.escape(value),
+                            )
+                        )
+                    )
+        elif len(args) != 2:
+            self.disp("bad syntax, please use:\nuse [arg] [value]", error=True)
+        else:
+            self.use[args[0]] = " ".join(args[1:])
+            if self.verbose:
+                self.disp(
+                    "set {name} = {value}".format(
+                        name=args[0], value=arg_tools.escape(args[1])
+                    )
+                )
+
+    def do_use_clear(self, args):
+        """unset one or many argument(s) in USE, or all of them if no arg is specified"""
+        args = self.parse_args(args)
+        if not args:
+            self.use.clear()
+        else:
+            for arg in args:
+                try:
+                    del self.use[arg]
+                except KeyError:
+                    self.disp(
+                        A.color(
+                            C.A_FAILURE, _("argument {name} not found").format(name=arg)
+                        ),
+                        error=True,
+                    )
+                else:
+                    if self.verbose:
+                        self.disp(_("argument {name} removed").format(name=arg))
+
+    def do_whoami(self, args):
+        """print profile currently used"""
+        self.disp(self.profile)
+
+    def do_quit(self, args):
+        """quit the shell"""
+        self.disp(_("good bye!"))
+        self.host.quit()
+
+    def do_exit(self, args):
+        """alias for quit"""
+        self.do_quit(args)
+
+    async def start(self):
+        # FIXME: "shell" is currently kept synchronous as it works well as it
+        #        and it will be refactored soon.
+        default_profile = self.host.bridge.profile_name_get(C.PROF_KEY_DEFAULT)
+        self._not_default_profile = self.profile != default_profile
+        self.path = []
+        self._cur_parser = self.host.parser
+        self.use = {}
+        self.verbose = False
+        self.update_path()
+        self.cmdloop(INTRO)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/cmd_uri.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common import uri
+
+__commands__ = ["Uri"]
+
+
+class Parse(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "parse",
+            use_profile=False,
+            use_output=C.OUTPUT_DICT,
+            help=_("parse URI"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "uri", help=_("XMPP URI to parse")
+        )
+
+    async def start(self):
+        await self.output(uri.parse_xmpp_uri(self.args.uri))
+        self.host.quit()
+
+
+class Build(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "build", use_profile=False, help=_("build URI")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("type", help=_("URI type"))
+        self.parser.add_argument("path", help=_("URI path"))
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            metavar=("KEY", "VALUE"),
+            help=_("URI fields"),
+        )
+
+    async def start(self):
+        fields = dict(self.args.fields) if self.args.fields else {}
+        self.disp(uri.build_xmpp_uri(self.args.type, path=self.args.path, **fields))
+        self.host.quit()
+
+
+class Uri(base.CommandBase):
+    subcommands = (Parse, Build)
+
+    def __init__(self, host):
+        super(Uri, self).__init__(
+            host, "uri", use_profile=False, help=_("XMPP URI parsing/generation")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/common.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,833 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import json
+import os
+import os.path
+import time
+import tempfile
+import asyncio
+import shlex
+import re
+from pathlib import Path
+from libervia.cli.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools import config
+from configparser import NoSectionError, NoOptionError
+from collections import namedtuple
+
+# default arguments used for some known editors (editing with metadata)
+VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'"
+EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"'
+EDITOR_ARGS_MAGIC = {
+    "vim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "nvim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "gvim": VIM_SPLIT_ARGS + " --nofork {content_file} {metadata_file}",
+    "emacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "xemacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "nano": " -F {content_file} {metadata_file}",
+}
+
+SECURE_UNLINK_MAX = 10
+SECURE_UNLINK_DIR = ".backup"
+METADATA_SUFF = "_metadata.json"
+
+
+def format_time(timestamp):
+    """Return formatted date for timestamp
+
+    @param timestamp(str,int,float): unix timestamp
+    @return (unicode): formatted date
+    """
+    fmt = "%d/%m/%Y %H:%M:%S %Z"
+    return time.strftime(fmt, time.localtime(float(timestamp)))
+
+
+def ansi_ljust(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansi_remove(s)
+    return s + " " * (width - len(cleaned))
+
+
+def ansi_center(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansi_remove(s)
+    diff = width - len(cleaned)
+    half = diff / 2
+    return half * " " + s + (half + diff % 2) * " "
+
+
+def ansi_rjust(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansi_remove(s)
+    return " " * (width - len(cleaned)) + s
+
+
+def get_tmp_dir(sat_conf, cat_dir, sub_dir=None):
+    """Return directory used to store temporary files
+
+    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
+    @param cat_dir(str): directory of the category (e.g. "blog")
+    @param sub_dir(str): sub directory where data need to be put
+        profile can be used here, or special directory name
+        sub_dir will be escaped to be usable in path (use regex.path_unescape to find
+        initial str)
+    @return (Path): path to the dir
+    """
+    local_dir = config.config_get(sat_conf, "", "local_dir", Exception)
+    path_elts = [local_dir, cat_dir]
+    if sub_dir is not None:
+        path_elts.append(regex.path_escape(sub_dir))
+    return Path(*path_elts)
+
+
+def parse_args(host, cmd_line, **format_kw):
+    """Parse command arguments
+
+    @param cmd_line(unicode): command line as found in sat.conf
+    @param format_kw: keywords used for formating
+    @return (list(unicode)): list of arguments to pass to subprocess function
+    """
+    try:
+        # we split the arguments and add the known fields
+        # we split arguments first to avoid escaping issues in file names
+        return [a.format(**format_kw) for a in shlex.split(cmd_line)]
+    except ValueError as e:
+        host.disp(
+            "Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e)
+        )
+        return []
+
+
+class BaseEdit(object):
+    """base class for editing commands
+
+    This class allows to edit file for PubSub or something else.
+    It works with temporary files in SàT local_dir, in a "cat_dir" subdir
+    """
+
+    def __init__(self, host, cat_dir, use_metadata=False):
+        """
+        @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
+        @param cat_dir(unicode): directory to use for drafts
+            this will be a sub-directory of SàT's local_dir
+        @param use_metadata(bool): True is edition need a second file for metadata
+            most of signature change with use_metadata with an additional metadata
+            argument.
+            This is done to raise error if a command needs metadata but forget the flag,
+            and vice versa
+        """
+        self.host = host
+        self.cat_dir = cat_dir
+        self.use_metadata = use_metadata
+
+    def secure_unlink(self, path):
+        """Unlink given path after keeping it for a while
+
+        This method is used to prevent accidental deletion of a draft
+        If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
+        older file are deleted
+        @param path(Path, str): file to unlink
+        """
+        path = Path(path).resolve()
+        if not path.is_file:
+            raise OSError("path must link to a regular file")
+        if path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
+            self.disp(
+                f"File {path} is not in SàT temporary hierarchy, we do not remove " f"it",
+                2,
+            )
+            return
+            # we have 2 files per draft with use_metadata, so we double max
+        unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX
+        backup_dir = get_tmp_dir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR)
+        if not os.path.exists(backup_dir):
+            os.makedirs(backup_dir)
+        filename = os.path.basename(path)
+        backup_path = os.path.join(backup_dir, filename)
+        # we move file to backup dir
+        self.host.disp(
+            "Backuping file {src} to {dst}".format(src=path, dst=backup_path),
+            1,
+        )
+        os.rename(path, backup_path)
+        # and if we exceeded the limit, we remove older file
+        backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
+        if len(backup_files) > unlink_max:
+            backup_files.sort(key=lambda path: os.stat(path).st_mtime)
+            for path in backup_files[: len(backup_files) - unlink_max]:
+                self.host.disp("Purging backup file {}".format(path), 2)
+                os.unlink(path)
+
+    async def run_editor(
+        self,
+        editor_args_opt,
+        content_file_path,
+        content_file_obj,
+        meta_file_path=None,
+        meta_ori=None,
+    ):
+        """Run editor to edit content and metadata
+
+        @param editor_args_opt(unicode): option in [cli] section in configuration for
+            specific args
+        @param content_file_path(str): path to the content file
+        @param content_file_obj(file): opened file instance
+        @param meta_file_path(str, Path, None): metadata file path
+            if None metadata will not be used
+        @param meta_ori(dict, None): original cotent of metadata
+            can't be used if use_metadata is False
+        """
+        if not self.use_metadata:
+            assert meta_file_path is None
+            assert meta_ori is None
+
+            # we calculate hashes to check for modifications
+        import hashlib
+
+        content_file_obj.seek(0)
+        tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
+        content_file_obj.close()
+
+        # we prepare arguments
+        editor = config.config_get(self.sat_conf, C.CONFIG_SECTION, "editor") or os.getenv(
+            "EDITOR", "vi"
+        )
+        try:
+            # is there custom arguments in sat.conf ?
+            editor_args = config.config_get(
+                self.sat_conf, C.CONFIG_SECTION, editor_args_opt, Exception
+            )
+        except (NoOptionError, NoSectionError):
+            # no, we check if we know the editor and have special arguments
+            if self.use_metadata:
+                editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), "")
+            else:
+                editor_args = ""
+        parse_kwargs = {"content_file": content_file_path}
+        if self.use_metadata:
+            parse_kwargs["metadata_file"] = meta_file_path
+        args = parse_args(self.host, editor_args, **parse_kwargs)
+        if not args:
+            args = [content_file_path]
+
+            # actual editing
+        editor_process = await asyncio.create_subprocess_exec(
+            editor, *[str(a) for a in args]
+        )
+        editor_exit = await editor_process.wait()
+
+        # edition will now be checked, and data will be sent if it was a success
+        if editor_exit != 0:
+            self.disp(
+                f"Editor exited with an error code, so temporary file has not be "
+                f"deleted, and item is not published.\nYou can find temporary file "
+                f"at {content_file_path}",
+                error=True,
+            )
+        else:
+            # main content
+            try:
+                with content_file_path.open("rb") as f:
+                    content = f.read()
+            except (OSError, IOError):
+                self.disp(
+                    f"Can read file at {content_file_path}, have it been deleted?\n"
+                    f"Cancelling edition",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+
+                # metadata
+            if self.use_metadata:
+                try:
+                    with meta_file_path.open("rb") as f:
+                        metadata = json.load(f)
+                except (OSError, IOError):
+                    self.disp(
+                        f"Can read file at {meta_file_path}, have it been deleted?\n"
+                        f"Cancelling edition",
+                        error=True,
+                    )
+                    self.host.quit(C.EXIT_NOT_FOUND)
+                except ValueError:
+                    self.disp(
+                        f"Can't parse metadata, please check it is correct JSON format. "
+                        f"Cancelling edition.\nYou can find tmp file at "
+                        f"{content_file_path} and temporary meta file at "
+                        f"{meta_file_path}.",
+                        error=True,
+                    )
+                    self.host.quit(C.EXIT_DATA_ERROR)
+
+            if self.use_metadata and not metadata.get("publish", True):
+                self.disp(
+                    f'Publication blocked by "publish" key in metadata, cancelling '
+                    f"edition.\n\ntemporary file path:\t{content_file_path}\nmetadata "
+                    f"file path:\t{meta_file_path}",
+                    error=True,
+                )
+                self.host.quit()
+
+            if len(content) == 0:
+                self.disp("Content is empty, cancelling the edition")
+                if content_file_path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
+                    self.disp(
+                        "File are not in SàT temporary hierarchy, we do not remove them",
+                        2,
+                    )
+                    self.host.quit()
+                self.disp(f"Deletion of {content_file_path}", 2)
+                os.unlink(content_file_path)
+                if self.use_metadata:
+                    self.disp(f"Deletion of {meta_file_path}".format(meta_file_path), 2)
+                    os.unlink(meta_file_path)
+                self.host.quit()
+
+                # time to re-check the hash
+            elif tmp_ori_hash == hashlib.sha1(content).digest() and (
+                not self.use_metadata or meta_ori == metadata
+            ):
+                self.disp("The content has not been modified, cancelling the edition")
+                self.host.quit()
+
+            else:
+                # we can now send the item
+                content = content.decode("utf-8-sig")  # we use utf-8-sig to avoid BOM
+                try:
+                    if self.use_metadata:
+                        await self.publish(content, metadata)
+                    else:
+                        await self.publish(content)
+                except Exception as e:
+                    if self.use_metadata:
+                        self.disp(
+                            f"Error while sending your item, the temporary files have "
+                            f"been kept at {content_file_path} and {meta_file_path}: "
+                            f"{e}",
+                            error=True,
+                        )
+                    else:
+                        self.disp(
+                            f"Error while sending your item, the temporary file has been "
+                            f"kept at {content_file_path}: {e}",
+                            error=True,
+                        )
+                    self.host.quit(1)
+
+            self.secure_unlink(content_file_path)
+            if self.use_metadata:
+                self.secure_unlink(meta_file_path)
+
+    async def publish(self, content):
+        # if metadata is needed, publish will be called with it last argument
+        raise NotImplementedError
+
+    def get_tmp_file(self):
+        """Create a temporary file
+
+        @return (tuple(file, Path)): opened (w+b) file object and file path
+        """
+        suff = "." + self.get_tmp_suff()
+        cat_dir_str = self.cat_dir
+        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, self.profile)
+        if not tmp_dir.exists():
+            try:
+                tmp_dir.mkdir(parents=True)
+            except OSError as e:
+                self.disp(
+                    f"Can't create {tmp_dir} directory: {e}",
+                    error=True,
+                )
+                self.host.quit(1)
+        try:
+            fd, path = tempfile.mkstemp(
+                suffix=suff,
+                prefix=time.strftime(cat_dir_str + "_%Y-%m-%d_%H:%M:%S_"),
+                dir=tmp_dir,
+                text=True,
+            )
+            return os.fdopen(fd, "w+b"), Path(path)
+        except OSError as e:
+            self.disp(f"Can't create temporary file: {e}", error=True)
+            self.host.quit(1)
+
+    def get_current_file(self, profile):
+        """Get most recently edited file
+
+        @param profile(unicode): profile linked to the draft
+        @return(Path): full path of current file
+        """
+        # we guess the item currently edited by choosing
+        # the most recent file corresponding to temp file pattern
+        # in tmp_dir, excluding metadata files
+        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, profile)
+        available = [
+            p
+            for p in tmp_dir.glob(f"{self.cat_dir}_*")
+            if not p.match(f"*{METADATA_SUFF}")
+        ]
+        if not available:
+            self.disp(
+                f"Could not find any content draft in {tmp_dir}",
+                error=True,
+            )
+            self.host.quit(1)
+        return max(available, key=lambda p: p.stat().st_mtime)
+
+    async def get_item_data(self, service, node, item):
+        """return formatted content, metadata (or not if use_metadata is false), and item id"""
+        raise NotImplementedError
+
+    def get_tmp_suff(self):
+        """return suffix used for content file"""
+        return "xml"
+
+    async def get_item_path(self):
+        """Retrieve item path (i.e. service and node) from item argument
+
+        This method is obviously only useful for edition of PubSub based features
+        """
+        service = self.args.service
+        node = self.args.node
+        item = self.args.item
+        last_item = self.args.last_item
+
+        if self.args.current:
+            # user wants to continue current draft
+            content_file_path = self.get_current_file(self.profile)
+            self.disp("Continuing edition of current draft", 2)
+            content_file_obj = content_file_path.open("r+b")
+            # we seek at the end of file in case of an item already exist
+            # this will write content of the existing item at the end of the draft.
+            # This way no data should be lost.
+            content_file_obj.seek(0, os.SEEK_END)
+        elif self.args.draft_path:
+            # there is an existing draft that we use
+            content_file_path = self.args.draft_path.expanduser()
+            content_file_obj = content_file_path.open("r+b")
+            # we seek at the end for the same reason as above
+            content_file_obj.seek(0, os.SEEK_END)
+        else:
+            # we need a temporary file
+            content_file_obj, content_file_path = self.get_tmp_file()
+
+        if item or last_item:
+            self.disp("Editing requested published item", 2)
+            try:
+                if self.use_metadata:
+                    content, metadata, item = await self.get_item_data(service, node, item)
+                else:
+                    content, item = await self.get_item_data(service, node, item)
+            except Exception as e:
+                # FIXME: ugly but we have not good may to check errors in bridge
+                if "item-not-found" in str(e):
+                    #  item doesn't exist, we create a new one with requested id
+                    metadata = None
+                    if last_item:
+                        self.disp(_("no item found at all, we create a new one"), 2)
+                    else:
+                        self.disp(
+                            _(
+                                'item "{item}" not found, we create a new item with'
+                                "this id"
+                            ).format(item=item),
+                            2,
+                        )
+                    content_file_obj.seek(0)
+                else:
+                    self.disp(f"Error while retrieving item: {e}")
+                    self.host.quit(C.EXIT_ERROR)
+            else:
+                # item exists, we write content
+                if content_file_obj.tell() != 0:
+                    # we already have a draft,
+                    # we copy item content after it and add an indicator
+                    content_file_obj.write("\n*****\n")
+                content_file_obj.write(content.encode("utf-8"))
+                content_file_obj.seek(0)
+                self.disp(_('item "{item}" found, we edit it').format(item=item), 2)
+        else:
+            self.disp("Editing a new item", 2)
+            if self.use_metadata:
+                metadata = None
+
+        if self.use_metadata:
+            return service, node, item, content_file_path, content_file_obj, metadata
+        else:
+            return service, node, item, content_file_path, content_file_obj
+
+
+class Table(object):
+    def __init__(self, host, data, headers=None, filters=None, use_buffer=False):
+        """
+        @param data(iterable[list]): table data
+            all lines must have the same number of columns
+        @param headers(iterable[unicode], None): names/titles of the columns
+            if not None, must have same number of columns as data
+        @param filters(iterable[(callable, unicode)], None): values filters
+            the callable will get 2 arguments:
+                - current column value
+                - RowData with all columns values
+            if may also only use 1 argument, which will then be current col value.
+            the callable must return a string
+            if it's unicode, it will be used with .format and must countain u'{}' which
+            will be replaced with the string.
+            if not None, must have same number of columns as data
+        @param use_buffer(bool): if True, bufferise output instead of printing it directly
+        """
+        self.host = host
+        self._buffer = [] if use_buffer else None
+        #  headers are columns names/titles, can be None
+        self.headers = headers
+        #  sizes fof columns without headers,
+        # headers may be larger
+        self.sizes = []
+        #  rows countains one list per row with columns values
+        self.rows = []
+
+        size = None
+        if headers:
+            # we use a namedtuple to make the value easily accessible from filters
+            headers_safe = [re.sub(r"[^a-zA-Z_]", "_", h) for h in headers]
+            row_cls = namedtuple("RowData", headers_safe)
+        else:
+            row_cls = tuple
+
+        for row_data in data:
+            new_row = []
+            row_data_list = list(row_data)
+            for idx, value in enumerate(row_data_list):
+                if filters is not None and filters[idx] is not None:
+                    filter_ = filters[idx]
+                    if isinstance(filter_, str):
+                        col_value = filter_.format(value)
+                    else:
+                        try:
+                            col_value = filter_(value, row_cls(*row_data_list))
+                        except TypeError:
+                            col_value = filter_(value)
+                            # we count size without ANSI code as they will change length of the
+                            # string when it's mostly style/color changes.
+                    col_size = len(regex.ansi_remove(col_value))
+                else:
+                    col_value = str(value)
+                    col_size = len(col_value)
+                new_row.append(col_value)
+                if size is None:
+                    self.sizes.append(col_size)
+                else:
+                    self.sizes[idx] = max(self.sizes[idx], col_size)
+            if size is None:
+                size = len(new_row)
+                if headers is not None and len(headers) != size:
+                    raise exceptions.DataError("headers size is not coherent with rows")
+            else:
+                if len(new_row) != size:
+                    raise exceptions.DataError("rows size is not coherent")
+            self.rows.append(new_row)
+
+        if not data and headers is not None:
+            #  the table is empty, we print headers at their lenght
+            self.sizes = [len(h) for h in headers]
+
+    @property
+    def string(self):
+        if self._buffer is None:
+            raise exceptions.InternalError("buffer must be used to get a string")
+        return "\n".join(self._buffer)
+
+    @staticmethod
+    def read_dict_values(data, keys, defaults=None):
+        if defaults is None:
+            defaults = {}
+        for key in keys:
+            try:
+                yield data[key]
+            except KeyError as e:
+                default = defaults.get(key)
+                if default is not None:
+                    yield default
+                else:
+                    raise e
+
+    @classmethod
+    def from_list_dict(
+        cls, host, data, keys=None, headers=None, filters=None, defaults=None
+    ):
+        """Create a table from a list of dictionaries
+
+        each dictionary is a row of the table, keys being columns names.
+        the whole data will be read and kept into memory, to be printed
+        @param data(list[dict[unicode, unicode]]): data to create the table from
+        @param keys(iterable[unicode], None): keys to get
+            if None, all keys will be used
+        @param headers(iterable[unicode], None): name of the columns
+            names must be in same order as keys
+        @param filters(dict[unicode, (callable,unicode)), None): filter to use on values
+            keys correspond to keys to filter, and value is the same as for Table.__init__
+        @param defaults(dict[unicode, unicode]): default value to use
+            if None, an exception will be raised if not value is found
+        """
+        if keys is None and headers is not None:
+            # FIXME: keys are not needed with OrderedDict,
+            raise exceptions.DataError("You must specify keys order to used headers")
+        if keys is None:
+            keys = list(data[0].keys())
+        if headers is None:
+            headers = keys
+        if filters is None:
+            filters = {}
+        filters = [filters.get(k) for k in keys]
+        return cls(
+            host, (cls.read_dict_values(d, keys, defaults) for d in data), headers, filters
+        )
+
+    def _headers(self, head_sep, headers, sizes, alignment="left", style=None):
+        """Render headers
+
+        @param head_sep(unicode): sequence to use as separator
+        @param alignment(unicode): how to align, can be left, center or right
+        @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply
+        @param headers(list[unicode]): headers to show
+        @param sizes(list[int]): sizes of columns
+        """
+        rendered_headers = []
+        if isinstance(style, str):
+            style = [style]
+        for idx, header in enumerate(headers):
+            size = sizes[idx]
+            if alignment == "left":
+                rendered = header[:size].ljust(size)
+            elif alignment == "center":
+                rendered = header[:size].center(size)
+            elif alignment == "right":
+                rendered = header[:size].rjust(size)
+            else:
+                raise exceptions.InternalError("bad alignment argument")
+            if style:
+                args = style + [rendered]
+                rendered = A.color(*args)
+            rendered_headers.append(rendered)
+        return head_sep.join(rendered_headers)
+
+    def _disp(self, data):
+        """output data (can be either bufferised or printed)"""
+        if self._buffer is not None:
+            self._buffer.append(data)
+        else:
+            self.host.disp(data)
+
+    def display(
+        self,
+        head_alignment="left",
+        columns_alignment="left",
+        head_style=None,
+        show_header=True,
+        show_borders=True,
+        hide_cols=None,
+        col_sep=" │ ",
+        top_left="┌",
+        top="─",
+        top_sep="─┬─",
+        top_right="┐",
+        left="│",
+        right=None,
+        head_sep=None,
+        head_line="┄",
+        head_line_left="├",
+        head_line_sep="┄┼┄",
+        head_line_right="┤",
+        bottom_left="└",
+        bottom=None,
+        bottom_sep="─┴─",
+        bottom_right="┘",
+    ):
+        """Print the table
+
+        @param show_header(bool): True if header need no be shown
+        @param show_borders(bool): True if borders need no be shown
+        @param hide_cols(None, iterable(unicode)): columns which should not be displayed
+        @param head_alignment(unicode): how to align headers, can be left, center or right
+        @param columns_alignment(unicode): how to align columns, can be left, center or
+            right
+        @param col_sep(unicode): separator betweens columns
+        @param head_line(unicode): character to use to make line under head
+        @param disp(callable, None): method to use to display the table
+            None to use self.host.disp
+        """
+        if not self.sizes:
+            # the table is empty
+            return
+        col_sep_size = len(regex.ansi_remove(col_sep))
+
+        # if we have columns to hide, we remove them from headers and size
+        if not hide_cols:
+            headers = self.headers
+            sizes = self.sizes
+        else:
+            headers = list(self.headers)
+            sizes = self.sizes[:]
+            ignore_idx = [headers.index(to_hide) for to_hide in hide_cols]
+            for to_hide in hide_cols:
+                hide_idx = headers.index(to_hide)
+                del headers[hide_idx]
+                del sizes[hide_idx]
+
+        if right is None:
+            right = left
+        if top_sep is None:
+            top_sep = col_sep_size * top
+        if head_sep is None:
+            head_sep = col_sep
+        if bottom is None:
+            bottom = top
+        if bottom_sep is None:
+            bottom_sep = col_sep_size * bottom
+        if not show_borders:
+            left = right = head_line_left = head_line_right = ""
+            # top border
+        if show_borders:
+            self._disp(
+                top_left + top_sep.join([top * size for size in sizes]) + top_right
+            )
+
+            # headers
+        if show_header and self.headers is not None:
+            self._disp(
+                left
+                + self._headers(head_sep, headers, sizes, head_alignment, head_style)
+                + right
+            )
+            # header line
+            self._disp(
+                head_line_left
+                + head_line_sep.join([head_line * size for size in sizes])
+                + head_line_right
+            )
+
+            # content
+        if columns_alignment == "left":
+            alignment = lambda idx, s: ansi_ljust(s, sizes[idx])
+        elif columns_alignment == "center":
+            alignment = lambda idx, s: ansi_center(s, sizes[idx])
+        elif columns_alignment == "right":
+            alignment = lambda idx, s: ansi_rjust(s, sizes[idx])
+        else:
+            raise exceptions.InternalError("bad columns alignment argument")
+
+        for row in self.rows:
+            if hide_cols:
+                row = [v for idx, v in enumerate(row) if idx not in ignore_idx]
+            self._disp(
+                left
+                + col_sep.join([alignment(idx, c) for idx, c in enumerate(row)])
+                + right
+            )
+
+        if show_borders:
+            # bottom border
+            self._disp(
+                bottom_left
+                + bottom_sep.join([bottom * size for size in sizes])
+                + bottom_right
+            )
+            #  we return self so string can be used after display (table.display().string)
+        return self
+
+    def display_blank(self, **kwargs):
+        """Display table without visible borders"""
+        kwargs_ = {"col_sep": " ", "head_line_sep": " ", "show_borders": False}
+        kwargs_.update(kwargs)
+        return self.display(**kwargs_)
+
+
+async def fill_well_known_uri(command, path, key, meta_map=None):
+    """Look for URIs in well-known location and fill appropriate args if suitable
+
+    @param command(CommandBase): command instance
+        args of this instance will be updated with found values
+    @param path(unicode): absolute path to use as a starting point to look for URIs
+    @param key(unicode): key to look for
+    @param meta_map(dict, None): if not None, map metadata to arg name
+        key is metadata used attribute name
+        value is name to actually use, or None to ignore
+        use empty dict to only retrieve URI
+        possible keys are currently:
+            - labels
+    """
+    args = command.args
+    if args.service or args.node:
+        # we only look for URIs if a service and a node are not already specified
+        return
+
+    host = command.host
+
+    try:
+        uris_data = await host.bridge.uri_find(path, [key])
+    except Exception as e:
+        host.disp(f"can't find {key} URI: {e}", error=True)
+        host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+    try:
+        uri_data = uris_data[key]
+    except KeyError:
+        host.disp(
+            _(
+                "No {key} URI specified for this project, please specify service and "
+                "node"
+            ).format(key=key),
+            error=True,
+        )
+        host.quit(C.EXIT_NOT_FOUND)
+
+    uri = uri_data["uri"]
+
+    # set extra metadata if they are specified
+    for data_key in ["labels"]:
+        new_values_json = uri_data.get(data_key)
+        if uri_data is not None:
+            if meta_map is None:
+                dest = data_key
+            else:
+                dest = meta_map.get(data_key)
+                if dest is None:
+                    continue
+
+            try:
+                values = getattr(args, data_key)
+            except AttributeError:
+                raise exceptions.InternalError(f"there is no {data_key!r} arguments")
+            else:
+                if values is None:
+                    values = []
+                values.extend(json.loads(new_values_json))
+                setattr(args, dest, values)
+
+    parsed_uri = xmpp_uri.parse_xmpp_uri(uri)
+    try:
+        args.service = parsed_uri["path"]
+        args.node = parsed_uri["node"]
+    except KeyError:
+        host.disp(_("Invalid URI found: {uri}").format(uri=uri), error=True)
+        host.quit(C.EXIT_DATA_ERROR)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/constants.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.frontends.quick_frontend import constants
+from libervia.backend.tools.common.ansi import ANSI as A
+
+
+class Const(constants.Const):
+
+    APP_NAME = "Libervia CLI"
+    APP_COMPONENT = "CLI"
+    APP_NAME_ALT = "jp"
+    APP_NAME_FILE = "libervia_cli"
+    CONFIG_SECTION = APP_COMPONENT.lower()
+    PLUGIN_CMD = "commands"
+    PLUGIN_OUTPUT = "outputs"
+    OUTPUT_TEXT = "text"  # blob of unicode text
+    OUTPUT_DICT = "dict"  # simple key/value dictionary
+    OUTPUT_LIST = "list"
+    OUTPUT_LIST_DICT = "list_dict"  # list of dictionaries
+    OUTPUT_DICT_DICT = "dict_dict"  # dict  of nested dictionaries
+    OUTPUT_MESS = "mess"  # messages (chat)
+    OUTPUT_COMPLEX = "complex"  # complex data (e.g. multi-level dictionary)
+    OUTPUT_XML = "xml"  # XML node (as unicode string)
+    OUTPUT_LIST_XML = "list_xml"  # list of XML nodes (as unicode strings)
+    OUTPUT_XMLUI = "xmlui"  # XMLUI as unicode string
+    OUTPUT_LIST_XMLUI = "list_xmlui"  # list of XMLUI (as unicode strings)
+    OUTPUT_TYPES = (
+        OUTPUT_TEXT,
+        OUTPUT_DICT,
+        OUTPUT_LIST,
+        OUTPUT_LIST_DICT,
+        OUTPUT_DICT_DICT,
+        OUTPUT_MESS,
+        OUTPUT_COMPLEX,
+        OUTPUT_XML,
+        OUTPUT_LIST_XML,
+        OUTPUT_XMLUI,
+        OUTPUT_LIST_XMLUI,
+    )
+    OUTPUT_NAME_SIMPLE = "simple"
+    OUTPUT_NAME_XML = "xml"
+    OUTPUT_NAME_XML_RAW = "xml-raw"
+    OUTPUT_NAME_JSON = "json"
+    OUTPUT_NAME_JSON_RAW = "json-raw"
+
+    # Pubsub options flags
+    SERVICE = "service"  # service required
+    NODE = "node"  # node required
+    ITEM = "item"  # item required
+    SINGLE_ITEM = "single_item"  # only one item is allowed
+    MULTI_ITEMS = "multi_items"  # multiple items are allowed
+    NO_MAX = "no_max"  # don't add --max option for multi items
+    CACHE = "cache"  # add cache control flag
+
+    # ANSI
+    A_HEADER = A.BOLD + A.FG_YELLOW
+    A_SUBHEADER = A.BOLD + A.FG_RED
+    # A_LEVEL_COLORS may be used to cycle on colors according to depth of data
+    A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
+    A_SUCCESS = A.BOLD + A.FG_GREEN
+    A_FAILURE = A.BOLD + A.FG_RED
+    A_WARNING = A.BOLD + A.FG_RED
+    #  A_PROMPT_* is for shell
+    A_PROMPT_PATH = A.BOLD + A.FG_CYAN
+    A_PROMPT_SUF = A.BOLD
+    # Files
+    A_DIRECTORY = A.BOLD + A.FG_CYAN
+    A_FILE = A.FG_WHITE
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/loops.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import asyncio
+import logging as log
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+
+log.basicConfig(level=log.WARNING,
+                format='[%(name)s] %(message)s')
+
+USER_INTER_MSG = _("User interruption: good bye")
+
+
+class QuitException(BaseException):
+    """Quitting is requested
+
+    This is used to stop execution when host.quit() is called
+    """
+
+
+def get_libervia_cli_loop(bridge_name):
+    if 'dbus' in bridge_name:
+        import signal
+        import threading
+        from gi.repository import GLib
+
+        class LiberviaCLILoop:
+
+            def run(self, libervia_cli, args, namespace):
+                signal.signal(signal.SIGINT, self._on_sigint)
+                self._glib_loop = GLib.MainLoop()
+                threading.Thread(target=self._glib_loop.run).start()
+                loop = asyncio.get_event_loop()
+                loop.run_until_complete(libervia_cli.main(args=args, namespace=namespace))
+                loop.run_forever()
+
+            def quit(self, exit_code):
+                loop = asyncio.get_event_loop()
+                loop.stop()
+                self._glib_loop.quit()
+                sys.exit(exit_code)
+
+            def call_later(self, delay, callback, *args):
+                """call a callback repeatedly
+
+                @param delay(int): delay between calls in s
+                @param callback(callable): method to call
+                    if the callback return True, the call will continue
+                    else the calls will stop
+                @param *args: args of the callbac
+                """
+                loop = asyncio.get_event_loop()
+                loop.call_later(delay, callback, *args)
+
+            def _on_sigint(self, sig_number, stack_frame):
+                """Called on keyboard interruption
+
+                Print user interruption message, set exit code and stop reactor
+                """
+                print("\r" + USER_INTER_MSG)
+                self.quit(C.EXIT_USER_CANCELLED)
+    else:
+        import signal
+        from twisted.internet import asyncioreactor
+        asyncioreactor.install()
+        from twisted.internet import reactor, defer
+
+        class LiberviaCLILoop:
+
+            def __init__(self):
+                # exit code must be set when using quit, so if it's not set
+                # something got wrong and we must report it
+                self._exit_code = C.EXIT_INTERNAL_ERROR
+
+            def run(self, libervia_cli, *args):
+                self.libervia_cli = libervia_cli
+                signal.signal(signal.SIGINT, self._on_sigint)
+                defer.ensureDeferred(self._start(libervia_cli, *args))
+                try:
+                    reactor.run(installSignalHandlers=False)
+                except SystemExit as e:
+                    self._exit_code = e.code
+                sys.exit(self._exit_code)
+
+            async def _start(self, libervia_cli, *args):
+                fut = asyncio.ensure_future(libervia_cli.main(*args))
+                try:
+                    await defer.Deferred.fromFuture(fut)
+                except BaseException:
+                    import traceback
+                    traceback.print_exc()
+                    libervia_cli.quit(1)
+
+            def quit(self, exit_code):
+                self._exit_code = exit_code
+                reactor.stop()
+
+            def _timeout_cb(self, args, callback, delay):
+                try:
+                    ret = callback(*args)
+                # FIXME: temporary hack to avoid traceback when using XMLUI
+                #        to be removed once create_task is not used anymore in
+                #        xmlui_manager (i.e. once libervia.frontends.tools.xmlui fully supports
+                #        async syntax)
+                except QuitException:
+                    return
+                if ret:
+                    reactor.callLater(delay, self._timeout_cb, args, callback, delay)
+
+            def call_later(self, delay, callback, *args):
+                reactor.callLater(delay, self._timeout_cb, args, callback, delay)
+
+            def _on_sigint(self, sig_number, stack_frame):
+                """Called on keyboard interruption
+
+                Print user interruption message, set exit code and stop reactor
+                """
+                print("\r" + USER_INTER_MSG)
+                self._exit_code = C.EXIT_USER_CANCELLED
+                reactor.callFromThread(reactor.stop)
+
+
+    if bridge_name == "embedded":
+        raise NotImplementedError
+        # from sat.core import sat_main
+        # sat = sat_main.SAT()
+
+    return LiberviaCLILoop
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/output_std.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,127 @@
+#! /usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""Standard outputs"""
+
+
+from libervia.cli.constants import Const as C
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import date_utils
+import json
+
+__outputs__ = ["Simple", "Json"]
+
+
+class Simple(object):
+    """Default outputs"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_SIMPLE, self.simple_print)
+        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_SIMPLE, self.list)
+        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict)
+        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_SIMPLE, self.list_dict)
+        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict_dict)
+        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_SIMPLE, self.messages)
+        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_SIMPLE, self.simple_print)
+
+    def simple_print(self, data):
+        self.host.disp(str(data))
+
+    def list(self, data):
+        self.host.disp("\n".join(data))
+
+    def dict(self, data, indent=0, header_color=C.A_HEADER):
+        options = self.host.parse_output_options()
+        self.host.check_output_options({"no-header"}, options)
+        show_header = not "no-header" in options
+        for k, v in data.items():
+            if show_header:
+                header = A.color(header_color, k) + ": "
+            else:
+                header = ""
+
+            self.host.disp(
+                (
+                    "{indent}{header}{value}".format(
+                        indent=indent * " ", header=header, value=v
+                    )
+                )
+            )
+
+    def list_dict(self, data):
+        for idx, datum in enumerate(data):
+            if idx:
+                self.host.disp("\n")
+            self.dict(datum)
+
+    def dict_dict(self, data):
+        for key, sub_dict in data.items():
+            self.host.disp(A.color(C.A_HEADER, key))
+            self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER)
+
+    def messages(self, data):
+        # TODO: handle lang, and non chat message (normal, headline)
+        for mess_data in data:
+            (uid, timestamp, from_jid, to_jid, message, subject, mess_type,
+             extra) = mess_data
+            time_str = date_utils.date_fmt(timestamp, "auto_day",
+                                           tz_info=date_utils.TZ_LOCAL)
+            from_jid = jid.JID(from_jid)
+            if mess_type == C.MESS_TYPE_GROUPCHAT:
+                nick = from_jid.resource
+            else:
+                nick = from_jid.node
+
+            if self.host.own_jid is not None and self.host.own_jid.bare == from_jid.bare:
+                nick_color = A.BOLD + A.FG_BLUE
+            else:
+                nick_color = A.BOLD + A.FG_YELLOW
+            message = list(message.values())[0] if message else ""
+
+            self.host.disp(A.color(
+                A.FG_CYAN, '['+time_str+'] ',
+                nick_color, nick, A.RESET, A.BOLD, '> ',
+                A.RESET, message))
+
+
+class Json(object):
+    """outputs in json format"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_JSON, self.dump)
+        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON_RAW, self.dump)
+
+    def dump(self, data):
+        self.host.disp(json.dumps(data, default=str))
+
+    def dump_pretty(self, data):
+        self.host.disp(json.dumps(data, indent=4, default=str))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/output_template.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,138 @@
+#! /usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""Standard outputs"""
+
+
+from libervia.cli.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log
+from libervia.backend.tools.common import template
+from functools import partial
+import logging
+import webbrowser
+import tempfile
+import os.path
+
+__outputs__ = ["Template"]
+TEMPLATE = "template"
+OPTIONS = {"template", "browser", "inline-css"}
+
+
+class Template(object):
+    """outputs data using SàT templates"""
+
+    def __init__(self, libervia_cli):
+        self.host = libervia_cli
+        libervia_cli.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render)
+
+    def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir):
+        """Get front URL for temporary directory"""
+        template_data = ctx['template_data']
+        return "file://" + os.path.join(tmp_dir, template_data.theme, relative_url)
+
+    def _do_render(self, template_path, css_inline, **kwargs):
+        try:
+            return self.renderer.render(template_path, css_inline=css_inline, **kwargs)
+        except template.TemplateNotFound:
+            self.host.disp(_("Can't find requested template: {template_path}")
+                .format(template_path=template_path), error=True)
+            self.host.quit(C.EXIT_NOT_FOUND)
+
+    def render(self, data):
+        """render output data using requested template
+
+        template to render the data can be either command's TEMPLATE or
+        template output_option requested by user.
+        @param data(dict): data is a dict which map from variable name to use in template
+            to the variable itself.
+            command's template_data_mapping attribute will be used if it exists to convert
+            data to a dict usable by the template.
+        """
+        # media_dir is needed for the template
+        self.host.media_dir = self.host.bridge.config_get("", "media_dir")
+        cmd = self.host.command
+        try:
+            template_path = cmd.TEMPLATE
+        except AttributeError:
+            if not "template" in cmd.args.output_opts:
+                self.host.disp(_(
+                    "no default template set for this command, you need to specify a "
+                    "template using --oo template=[path/to/template.html]"),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+
+        options = self.host.parse_output_options()
+        self.host.check_output_options(OPTIONS, options)
+        try:
+            template_path = options["template"]
+        except KeyError:
+            # template is not specified, we use default one
+            pass
+        if template_path is None:
+            self.host.disp(_("Can't parse template, please check its syntax"),
+                           error=True)
+            self.host.quit(C.EXIT_BAD_ARG)
+
+        try:
+            mapping_cb = cmd.template_data_mapping
+        except AttributeError:
+            kwargs = data
+        else:
+            kwargs = mapping_cb(data)
+
+        css_inline = "inline-css" in options
+
+        if "browser" in options:
+            template_name = os.path.basename(template_path)
+            tmp_dir = tempfile.mkdtemp()
+            front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir)
+            self.renderer = template.Renderer(
+                self.host, front_url_filter=front_url_filter, trusted=True)
+            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
+            self.host.disp(_(
+                "Browser opening requested.\n"
+                "Temporary files are put in the following directory, you'll have to "
+                "delete it yourself once finished viewing: {}").format(tmp_dir))
+            tmp_file = os.path.join(tmp_dir, template_name)
+            with open(tmp_file, "w") as f:
+                f.write(rendered.encode("utf-8"))
+            theme, theme_root_path = self.renderer.get_theme_and_root(template_path)
+            if theme is None:
+                # we have an absolute path
+                webbrowser
+            static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR)
+            if os.path.exists(static_dir):
+                # we have to copy static files in a subdirectory, to avoid file download
+                # to be blocked by same origin policy
+                import shutil
+                shutil.copytree(
+                    static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR)
+                )
+            webbrowser.open(tmp_file)
+        else:
+            # FIXME: Q&D way to disable template logging
+            #        logs are overcomplicated, and need to be reworked
+            template_logger = log.getLogger("sat.tools.common.template")
+            template_logger.log = lambda *args: None
+
+            logging.disable(logging.WARNING)
+            self.renderer = template.Renderer(self.host, trusted=True)
+            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
+            self.host.disp(rendered)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/output_xml.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,90 @@
+#! /usr/bin/env python3
+
+# Libervia CLI frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""Standard outputs"""
+
+
+from libervia.cli.constants import Const as C
+from libervia.backend.core.i18n import _
+from lxml import etree
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+import sys
+
+try:
+    import pygments
+    from pygments.lexers.html import XmlLexer
+    from pygments.formatters import TerminalFormatter
+except ImportError:
+    pygments = None
+
+
+__outputs__ = ["XML"]
+
+
+class XML(object):
+    """Outputs for XML"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML, self.pretty, default=True)
+        host.register_output(
+            C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML, self.pretty_list, default=True
+        )
+        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML_RAW, self.raw)
+        host.register_output(C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML_RAW, self.list_raw)
+
+    def colorize(self, xml):
+        if pygments is None:
+            self.host.disp(
+                _(
+                    "Pygments is not available, syntax highlighting is not possible. "
+                    "Please install if from http://pygments.org or with pip install "
+                    "pygments"
+                ),
+                error=True,
+            )
+            return xml
+        if not sys.stdout.isatty():
+            return xml
+        lexer = XmlLexer(encoding="utf-8")
+        formatter = TerminalFormatter(bg="dark")
+        return pygments.highlight(xml, lexer, formatter)
+
+    def format(self, data, pretty=True):
+        parser = etree.XMLParser(remove_blank_text=True)
+        tree = etree.fromstring(data, parser)
+        xml = etree.tostring(tree, encoding="unicode", pretty_print=pretty)
+        return self.colorize(xml)
+
+    def format_no_pretty(self, data):
+        return self.format(data, pretty=False)
+
+    def pretty(self, data):
+        self.host.disp(self.format(data))
+
+    def pretty_list(self, data, separator="\n"):
+        list_pretty = list(map(self.format, data))
+        self.host.disp(separator.join(list_pretty))
+
+    def raw(self, data):
+        self.host.disp(self.format_no_pretty(data))
+
+    def list_raw(self, data, separator="\n"):
+        list_no_pretty = list(map(self.format_no_pretty, data))
+        self.host.disp(separator.join(list_no_pretty))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/output_xmlui.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,49 @@
+#! /usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""Standard outputs"""
+
+
+from libervia.cli.constants import Const as C
+from libervia.cli import xmlui_manager
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+__outputs__ = ["XMLUI"]
+
+
+class XMLUI(object):
+    """Outputs for XMLUI"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_XMLUI, "simple", self.xmlui, default=True)
+        host.register_output(
+            C.OUTPUT_LIST_XMLUI, "simple", self.xmlui_list, default=True
+        )
+
+    async def xmlui(self, data):
+        xmlui = xmlui_manager.create(self.host, data)
+        await xmlui.show(values_only=True, read_only=True)
+        self.host.disp("")
+
+    async def xmlui_list(self, data):
+        for d in data:
+            await self.xmlui(d)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/xml_tools.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.cli.constants import Const as C
+
+def etree_parse(cmd, raw_xml, reraise=False):
+    """import lxml and parse raw XML
+
+    @param cmd(CommandBase): current command instance
+    @param raw_xml(file, str): an XML bytestring, string or file-like object
+    @param reraise(bool): if True, re raise exception on parse error instead of doing a
+        parser.error (which terminate the execution)
+    @return (tuple(etree.Element, module): parsed element, etree module
+    """
+    try:
+        from lxml import etree
+    except ImportError:
+        cmd.disp(
+            'lxml module must be installed, please install it with "pip install lxml"',
+            error=True,
+        )
+        cmd.host.quit(C.EXIT_ERROR)
+    try:
+        if isinstance(raw_xml, str):
+            parser = etree.XMLParser(remove_blank_text=True)
+            element = etree.fromstring(raw_xml, parser)
+        else:
+            element = etree.parse(raw_xml).getroot()
+    except Exception as e:
+        if reraise:
+            raise e
+        cmd.parser.error(
+            _("Can't parse the payload XML in input: {msg}").format(msg=e)
+        )
+    return element, etree
+
+def get_payload(cmd, element):
+    """Retrieve payload element and exit with and error if not found
+
+    @param element(etree.Element): root element
+    @return element(etree.Element): payload element
+    """
+    if element.tag in ("item", "{http://jabber.org/protocol/pubsub}item"):
+        if len(element) > 1:
+            cmd.disp(_("<item> can only have one child element (the payload)"),
+                     error=True)
+            cmd.host.quit(C.EXIT_DATA_ERROR)
+        element = element[0]
+    return element
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/xmlui_manager.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,652 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from functools import partial
+from libervia.backend.core.log import getLogger
+from libervia.frontends.tools import xmlui as xmlui_base
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+# workflow constants
+
+SUBMIT = "SUBMIT"  # submit form
+
+
+## Widgets ##
+
+
+class Base(object):
+    """Base for Widget and Container"""
+
+    type = None
+    _root = None
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.host = self.xmlui_parent.host
+
+    @property
+    def root(self):
+        """retrieve main XMLUI parent class"""
+        if self._root is not None:
+            return self._root
+        root = self
+        while not isinstance(root, xmlui_base.XMLUIBase):
+            root = root.xmlui_parent
+        self._root = root
+        return root
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+
+class Widget(Base):
+    category = "widget"
+    enabled = True
+
+    @property
+    def name(self):
+        return self._xmlui_name
+
+    async def show(self):
+        """display current widget
+
+        must be overriden by subclasses
+        """
+        raise NotImplementedError(self.__class__)
+
+    def verbose_name(self, elems=None, value=None):
+        """add name in color to the elements
+
+        helper method to display name which can then be used to automate commands
+        elems is only modified if verbosity is > 0
+        @param elems(list[unicode], None): elements to display
+            None to display name directly
+        @param value(unicode, None): value to show
+            use self.name if None
+        """
+        if value is None:
+            value = self.name
+        if self.host.verbosity:
+            to_disp = [
+                A.FG_MAGENTA,
+                " " if elems else "",
+                "({})".format(value),
+                A.RESET,
+            ]
+            if elems is None:
+                self.host.disp(A.color(*to_disp))
+            else:
+                elems.extend(to_disp)
+
+
+class ValueWidget(Widget):
+    def __init__(self, xmlui_parent, value):
+        super(ValueWidget, self).__init__(xmlui_parent)
+        self.value = value
+
+    @property
+    def values(self):
+        return [self.value]
+
+
+class InputWidget(ValueWidget):
+    def __init__(self, xmlui_parent, value, read_only=False):
+        super(InputWidget, self).__init__(xmlui_parent, value)
+        self.read_only = read_only
+
+    def _xmlui_get_value(self):
+        return self.value
+
+
+class OptionsWidget(Widget):
+    def __init__(self, xmlui_parent, options, selected, style):
+        super(OptionsWidget, self).__init__(xmlui_parent)
+        self.options = options
+        self.selected = selected
+        self.style = style
+
+    @property
+    def values(self):
+        return self.selected
+
+    @values.setter
+    def values(self, values):
+        self.selected = values
+
+    @property
+    def value(self):
+        return self.selected[0]
+
+    @value.setter
+    def value(self, value):
+        self.selected = [value]
+
+    def _xmlui_select_value(self, value):
+        self.value = value
+
+    def _xmlui_select_values(self, values):
+        self.values = values
+
+    def _xmlui_get_selected_values(self):
+        return self.values
+
+    @property
+    def labels(self):
+        """return only labels from self.items"""
+        for value, label in self.items:
+            yield label
+
+    @property
+    def items(self):
+        """return suitable items, according to style"""
+        no_select = self.no_select
+        for value, label in self.options:
+            if no_select or value in self.selected:
+                yield value, label
+
+    @property
+    def inline(self):
+        return "inline" in self.style
+
+    @property
+    def no_select(self):
+        return "noselect" in self.style
+
+
+class EmptyWidget(xmlui_base.EmptyWidget, Widget):
+    def __init__(self, xmlui_parent):
+        Widget.__init__(self, xmlui_parent)
+
+    async def show(self):
+        self.host.disp("")
+
+
+class TextWidget(xmlui_base.TextWidget, ValueWidget):
+    type = "text"
+
+    async def show(self):
+        self.host.disp(self.value)
+
+
+class LabelWidget(xmlui_base.LabelWidget, ValueWidget):
+    type = "label"
+
+    @property
+    def for_name(self):
+        try:
+            return self._xmlui_for_name
+        except AttributeError:
+            return None
+
+    async def show(self, end="\n", ansi=""):
+        """show label
+
+        @param end(str): same as for [LiberviaCli.disp]
+        @param ansi(unicode): ansi escape code to print before label
+        """
+        self.disp(A.color(ansi, self.value), end=end)
+
+
+class JidWidget(xmlui_base.JidWidget, TextWidget):
+    type = "jid"
+
+
+class StringWidget(xmlui_base.StringWidget, InputWidget):
+    type = "string"
+
+    async def show(self):
+        if self.read_only or self.root.read_only:
+            self.disp(self.value)
+        else:
+            elems = []
+            self.verbose_name(elems)
+            if self.value:
+                elems.append(_("(enter: {value})").format(value=self.value))
+            elems.extend([C.A_HEADER, "> "])
+            value = await self.host.ainput(A.color(*elems))
+            if value:
+                #  TODO: empty value should be possible
+                #       an escape key should be used for default instead of enter with empty value
+                self.value = value
+
+
+class JidInputWidget(xmlui_base.JidInputWidget, StringWidget):
+    type = "jid_input"
+
+
+class PasswordWidget(xmlui_base.PasswordWidget, StringWidget):
+    type = "password"
+
+
+class TextBoxWidget(xmlui_base.TextWidget, StringWidget):
+    type = "textbox"
+    # TODO: use a more advanced input method
+
+    async def show(self):
+        self.verbose_name()
+        if self.read_only or self.root.read_only:
+            self.disp(self.value)
+        else:
+            if self.value:
+                self.disp(
+                    A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "")
+                )
+
+            values = []
+            while True:
+                try:
+                    if not values:
+                        line = await self.host.ainput(
+                            A.color(C.A_HEADER, "[Ctrl-D to finish]> ")
+                        )
+                    else:
+                        line = await self.host.ainput()
+                    values.append(line)
+                except EOFError:
+                    break
+
+            self.value = "\n".join(values).rstrip()
+
+
+class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget):
+    type = "xhtmlbox"
+
+    async def show(self):
+        # FIXME: we use bridge in a blocking way as permitted by python-dbus
+        #        this only for now to make it simpler, it must be refactored to use async
+        #        when libervia-cli will be fully async (expected for 0.8)
+        self.value = await self.host.bridge.syntax_convert(
+            self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile
+        )
+        await super(XHTMLBoxWidget, self).show()
+
+
+class ListWidget(xmlui_base.ListWidget, OptionsWidget):
+    type = "list"
+    # TODO: handle flags, notably multi
+
+    async def show(self):
+        if self.root.values_only:
+            for value in self.values:
+                self.disp(self.value)
+                return
+        if not self.options:
+            return
+
+            # list display
+        self.verbose_name()
+
+        for idx, (value, label) in enumerate(self.options):
+            elems = []
+            if not self.root.read_only:
+                elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "])
+            elems.append(label)
+            self.verbose_name(elems, value)
+            self.disp(A.color(*elems))
+
+        if self.root.read_only:
+            return
+
+        if len(self.options) == 1:
+            # we have only one option, no need to ask
+            self.value = self.options[0][0]
+            return
+
+            #  we ask use to choose an option
+        choice = None
+        limit_max = len(self.options) - 1
+        while choice is None or choice < 0 or choice > limit_max:
+            choice = await self.host.ainput(
+                A.color(
+                    C.A_HEADER,
+                    _("your choice (0-{limit_max}): ").format(limit_max=limit_max),
+                )
+            )
+            try:
+                choice = int(choice)
+            except ValueError:
+                choice = None
+        self.value = self.options[choice][0]
+        self.disp("")
+
+
+class BoolWidget(xmlui_base.BoolWidget, InputWidget):
+    type = "bool"
+
+    async def show(self):
+        disp_true = A.color(A.FG_GREEN, "TRUE")
+        disp_false = A.color(A.FG_RED, "FALSE")
+        if self.read_only or self.root.read_only:
+            self.disp(disp_true if self.value else disp_false)
+        else:
+            self.disp(
+                A.color(
+                    C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else ""
+                )
+            )
+            self.disp(
+                A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "")
+            )
+            choice = None
+            while choice not in ("0", "1"):
+                elems = [C.A_HEADER, _("your choice (0,1): ")]
+                self.verbose_name(elems)
+                choice = await self.host.ainput(A.color(*elems))
+            self.value = bool(int(choice))
+            self.disp("")
+
+    def _xmlui_get_value(self):
+        return C.bool_const(self.value)
+
+        ## Containers ##
+
+
+class Container(Base):
+    category = "container"
+
+    def __init__(self, xmlui_parent):
+        super(Container, self).__init__(xmlui_parent)
+        self.children = []
+
+    def __iter__(self):
+        return iter(self.children)
+
+    def _xmlui_append(self, widget):
+        self.children.append(widget)
+
+    def _xmlui_remove(self, widget):
+        self.children.remove(widget)
+
+    async def show(self):
+        for child in self.children:
+            await child.show()
+
+
+class VerticalContainer(xmlui_base.VerticalContainer, Container):
+    type = "vertical"
+
+
+class PairsContainer(xmlui_base.PairsContainer, Container):
+    type = "pairs"
+
+
+class LabelContainer(xmlui_base.PairsContainer, Container):
+    type = "label"
+
+    async def show(self):
+        for child in self.children:
+            end = "\n"
+            # we check linked widget type
+            # to see if we want the label on the same line or not
+            if child.type == "label":
+                for_name = child.for_name
+                if for_name:
+                    for_widget = self.root.widgets[for_name]
+                    wid_type = for_widget.type
+                    if self.root.values_only or wid_type in (
+                        "text",
+                        "string",
+                        "jid_input",
+                    ):
+                        end = " "
+                    elif wid_type == "bool" and for_widget.read_only:
+                        end = " "
+                await child.show(end=end, ansi=A.FG_CYAN)
+            else:
+                await child.show()
+
+                ## Dialogs ##
+
+
+class Dialog(object):
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.host = self.xmlui_parent.host
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+    async def show(self):
+        """display current dialog
+
+        must be overriden by subclasses
+        """
+        raise NotImplementedError(self.__class__)
+
+
+class MessageDialog(xmlui_base.MessageDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.MessageDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    async def show(self):
+        # TODO: handle level
+        if self.title:
+            self.disp(A.color(C.A_HEADER, self.title))
+        self.disp(self.message)
+
+
+class NoteDialog(xmlui_base.NoteDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.NoteDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    async def show(self):
+        # TODO: handle title
+        error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR)
+        if self.level == C.XMLUI_DATA_LVL_WARNING:
+            msg = A.color(C.A_WARNING, self.message)
+        elif self.level == C.XMLUI_DATA_LVL_ERROR:
+            msg = A.color(C.A_FAILURE, self.message)
+        else:
+            msg = self.message
+        self.disp(msg, error=error)
+
+
+class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level, buttons_set):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.ConfirmDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level, self.buttons_set = (
+            title,
+            message,
+            level,
+            buttons_set,
+        )
+
+    async def show(self):
+        # TODO: handle buttons_set and level
+        self.disp(self.message)
+        if self.title:
+            self.disp(A.color(C.A_HEADER, self.title))
+        input_ = None
+        while input_ not in ("y", "n"):
+            input_ = await self.host.ainput(f"{self.message} (y/n)? ")
+            input_ = input_.lower()
+        if input_ == "y":
+            self._xmlui_validated()
+        else:
+            self._xmlui_cancelled()
+
+            ## Factory ##
+
+
+class WidgetFactory(object):
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = globals()[attr[6:]]
+            return cls
+
+
+class XMLUIPanel(xmlui_base.AIOXMLUIPanel):
+    widget_factory = WidgetFactory()
+    _actions = 0  # use to keep track of bridge's action_launch calls
+    read_only = False
+    values_only = False
+    workflow = None
+    _submit_cb = None
+
+    def __init__(
+        self,
+        host,
+        parsed_dom,
+        title=None,
+        flags=None,
+        callback=None,
+        ignore=None,
+        whitelist=None,
+        profile=None,
+    ):
+        xmlui_base.XMLUIPanel.__init__(
+            self,
+            host,
+            parsed_dom,
+            title=title,
+            flags=flags,
+            ignore=ignore,
+            whitelist=whitelist,
+            profile=host.profile,
+        )
+        self.submitted = False
+
+    @property
+    def command(self):
+        return self.host.command
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+    async def show(self, workflow=None, read_only=False, values_only=False):
+        """display the panel
+
+        @param workflow(list, None): command to execute if not None
+            put here for convenience, the main workflow is the class attribute
+            (because workflow can continue in subclasses)
+            command are a list of consts or lists:
+                - SUBMIT is the only constant so far, it submits the XMLUI
+                - list must contain widget name/widget value to fill
+        @param read_only(bool): if True, don't request values
+        @param values_only(bool): if True, only show select values (imply read_only)
+        """
+        self.read_only = read_only
+        self.values_only = values_only
+        if self.values_only:
+            self.read_only = True
+        if workflow:
+            XMLUIPanel.workflow = workflow
+        if XMLUIPanel.workflow:
+            await self.run_workflow()
+        else:
+            await self.main_cont.show()
+
+    async def run_workflow(self):
+        """loop into workflow commands and execute commands
+
+        SUBMIT will interrupt workflow (which will be continue on callback)
+        @param workflow(list): same as [show]
+        """
+        workflow = XMLUIPanel.workflow
+        while True:
+            try:
+                cmd = workflow.pop(0)
+            except IndexError:
+                break
+            if cmd == SUBMIT:
+                await self.on_form_submitted()
+                self.submit_id = None  # avoid double submit
+                return
+            elif isinstance(cmd, list):
+                name, value = cmd
+                widget = self.widgets[name]
+                if widget.type == "bool":
+                    value = C.bool(value)
+                widget.value = value
+        await self.show()
+
+    async def submit_form(self, callback=None):
+        XMLUIPanel._submit_cb = callback
+        await self.on_form_submitted()
+
+    async def on_form_submitted(self, ignore=None):
+        # self.submitted is a Q&D workaround to avoid
+        # double submit when a workflow is set
+        if self.submitted:
+            return
+        self.submitted = True
+        await super(XMLUIPanel, self).on_form_submitted(ignore)
+
+    def _xmlui_close(self):
+        pass
+
+    async def _launch_action_cb(self, data):
+        XMLUIPanel._actions -= 1
+        assert XMLUIPanel._actions >= 0
+        if "xmlui" in data:
+            xmlui_raw = data["xmlui"]
+            xmlui = create(self.host, xmlui_raw)
+            await xmlui.show()
+            if xmlui.submit_id:
+                await xmlui.on_form_submitted()
+                # TODO: handle data other than XMLUI
+        if not XMLUIPanel._actions:
+            if self._submit_cb is None:
+                self.host.quit()
+            else:
+                self._submit_cb()
+
+    async def _xmlui_launch_action(self, action_id, data):
+        XMLUIPanel._actions += 1
+        try:
+            data = data_format.deserialise(
+                await self.host.bridge.action_launch(
+                    action_id,
+                    data_format.serialise(data),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't launch XMLUI action: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self._launch_action_cb(data)
+
+
+class XMLUIDialog(xmlui_base.XMLUIDialog):
+    type = "dialog"
+    dialog_factory = WidgetFactory()
+    read_only = False
+
+    async def show(self, __=None):
+        await self.dlg.show()
+
+    def _xmlui_close(self):
+        pass
+
+
+create = partial(
+    xmlui_base.create,
+    class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog},
+)
--- a/libervia/frontends/jp/arg_tools.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-
-
-def escape(arg, smart=True):
-    """format arg with quotes
-
-    @param smart(bool): if True, only escape if needed
-    """
-    if smart and not " " in arg and not '"' in arg:
-        return arg
-    return '"' + arg.replace('"', '\\"') + '"'
-
-
-def get_cmd_choices(cmd=None, parser=None):
-    try:
-        choices = parser._subparsers._group_actions[0].choices
-        return choices[cmd] if cmd is not None else choices
-    except (KeyError, AttributeError):
-        raise exceptions.NotFound
-
-
-def get_use_args(host, args, use, verbose=False, parser=None):
-    """format args for argparse parser with values prefilled
-
-    @param host(JP): jp instance
-    @param args(list(str)): arguments to use
-    @param use(dict[str, str]): arguments to fill if found in parser
-    @param verbose(bool): if True a message will be displayed when argument is used or not
-    @param parser(argparse.ArgumentParser): parser to use
-    @return (tuple[list[str],list[str]]): 2 args lists:
-        - parser args, i.e. given args corresponding to parsers
-        - use args, i.e. generated args from use
-    """
-    # FIXME: positional args are not handled correclty
-    #        if there is more that one, the position is not corrected
-    if parser is None:
-        parser = host.parser
-
-    # we check not optional args to see if there
-    # is a corresonding parser
-    # else USE args would not work correctly (only for current parser)
-    parser_args = []
-    for arg in args:
-        if arg.startswith("-"):
-            break
-        try:
-            parser = get_cmd_choices(arg, parser)
-        except exceptions.NotFound:
-            break
-        parser_args.append(arg)
-
-    # post_args are remaning given args,
-    # without the ones corresponding to parsers
-    post_args = args[len(parser_args) :]
-
-    opt_args = []
-    pos_args = []
-    actions = {a.dest: a for a in parser._actions}
-    for arg, value in use.items():
-        try:
-            if arg == "item" and not "item" in actions:
-                # small hack when --item is appended to a --items list
-                arg = "items"
-            action = actions[arg]
-        except KeyError:
-            if verbose:
-                host.disp(
-                    _(
-                        "ignoring {name}={value}, not corresponding to any argument (in USE)"
-                    ).format(name=arg, value=escape(value))
-                )
-        else:
-            if verbose:
-                host.disp(
-                    _("arg {name}={value} (in USE)").format(
-                        name=arg, value=escape(value)
-                    )
-                )
-            if not action.option_strings:
-                pos_args.append(value)
-            else:
-                opt_args.append(action.option_strings[0])
-                opt_args.append(value)
-    return parser_args, opt_args + pos_args + post_args
--- a/libervia/frontends/jp/base.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1435 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import asyncio
-from libervia.backend.core.i18n import _
-
-### logging ###
-import logging as log
-log.basicConfig(level=log.WARNING,
-                format='[%(name)s] %(message)s')
-###
-
-import sys
-import os
-import os.path
-import argparse
-import inspect
-import tty
-import termios
-from pathlib import Path
-from glob import iglob
-from typing import Optional, Set, Union
-from importlib import import_module
-from libervia.frontends.tools.jid import JID
-from libervia.backend.tools import config
-from libervia.backend.tools.common import dynamic_import
-from libervia.backend.tools.common import uri
-from libervia.backend.tools.common import date_utils
-from libervia.backend.tools.common import utils
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.core import exceptions
-import libervia.frontends.jp
-from libervia.frontends.jp.loops import QuitException, get_jp_loop
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.bridge.bridge_frontend import BridgeException
-from libervia.frontends.tools import misc
-import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
-from collections import OrderedDict
-
-## bridge handling
-# we get bridge name from conf and initialise the right class accordingly
-main_config = config.parse_main_conf()
-bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
-JPLoop = get_jp_loop(bridge_name)
-
-
-try:
-    import progressbar
-except ImportError:
-    msg = (_('ProgressBar not available, please download it at '
-             'http://pypi.python.org/pypi/progressbar\n'
-             'Progress bar deactivated\n--\n'))
-    print(msg, file=sys.stderr)
-    progressbar=None
-
-#consts
-DESCRIPTION = """This software is a command line tool for XMPP.
-Get the latest version at """ + C.APP_URL
-
-COPYLEFT = """Copyright (C) 2009-2021 Jérôme Poisson, Adrien Cossa
-This program comes with ABSOLUTELY NO WARRANTY;
-This is free software, and you are welcome to redistribute it under certain conditions.
-"""
-
-PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s
-
-
-def date_decoder(arg):
-    return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL)
-
-
-class LiberviaCli:
-    """
-    This class can be use to establish a connection with the
-    bridge. Moreover, it should manage a main loop.
-
-    To use it, you mainly have to redefine the method run to perform
-    specify what kind of operation you want to perform.
-
-    """
-    def __init__(self):
-        """
-
-        @attribute quit_on_progress_end (bool): set to False if you manage yourself
-            exiting, or if you want the user to stop by himself
-        @attribute progress_success(callable): method to call when progress just started
-            by default display a message
-        @attribute progress_success(callable): method to call when progress is
-            successfully finished by default display a message
-        @attribute progress_failure(callable): method to call when progress failed
-            by default display a message
-        """
-        self.sat_conf = main_config
-        self.set_color_theme()
-        bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge')
-        if bridge_module is None:
-            log.error("Can't import {} bridge".format(bridge_name))
-            sys.exit(1)
-
-        self.bridge = bridge_module.AIOBridge()
-        self._onQuitCallbacks = []
-
-    def get_config(self, name, section=C.CONFIG_SECTION, default=None):
-        """Retrieve a setting value from sat.conf"""
-        return config.config_get(self.sat_conf, section, name, default=default)
-
-    def guess_background(self):
-        # cf. https://unix.stackexchange.com/a/245568 (thanks!)
-        try:
-            # for VTE based terminals
-            vte_version = int(os.getenv("VTE_VERSION", 0))
-        except ValueError:
-            vte_version = 0
-
-        color_fg_bg = os.getenv("COLORFGBG")
-
-        if ((sys.stdin.isatty() and sys.stdout.isatty()
-             and (
-                 # XTerm
-                 os.getenv("XTERM_VERSION")
-                 # Konsole
-                 or os.getenv("KONSOLE_VERSION")
-                 # All VTE based terminals
-                 or vte_version >= 3502
-             ))):
-            # ANSI escape sequence
-            stdin_fd = sys.stdin.fileno()
-            old_settings = termios.tcgetattr(stdin_fd)
-            try:
-                tty.setraw(sys.stdin.fileno())
-                # we request background color
-                sys.stdout.write("\033]11;?\a")
-                sys.stdout.flush()
-                expected = "\033]11;rgb:"
-                for c in expected:
-                    ch = sys.stdin.read(1)
-                    if ch != c:
-                        # background id is not supported, we default to "dark"
-                        # TODO: log something?
-                        return 'dark'
-                red, green, blue = [
-                    int(c, 16)/65535 for c in sys.stdin.read(14).split('/')
-                ]
-                # '\a' is the last character
-                sys.stdin.read(1)
-            finally:
-                termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
-
-            lum = utils.per_luminance(red, green, blue)
-            if lum <= 0.5:
-                return 'dark'
-            else:
-                return 'light'
-        elif color_fg_bg:
-            # no luck with ANSI escape sequence, we try COLORFGBG environment variable
-            try:
-                bg = int(color_fg_bg.split(";")[-1])
-            except ValueError:
-                return "dark"
-            if bg in list(range(7)) + [8]:
-                return "dark"
-            else:
-                return "light"
-        else:
-            # no autodetection method found
-            return "dark"
-
-    def set_color_theme(self):
-        background = self.get_config('background', default='auto')
-        if background == 'auto':
-            background = self.guess_background()
-        if background not in ('dark', 'light'):
-            raise exceptions.ConfigError(_(
-                'Invalid value set for "background" ({background}), please check '
-                'your settings in libervia.conf').format(
-                    background=repr(background)
-                ))
-        self.background = background
-        if background == 'light':
-            C.A_HEADER = A.FG_MAGENTA
-            C.A_SUBHEADER = A.BOLD + A.FG_RED
-            C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
-            C.A_SUCCESS = A.FG_GREEN
-            C.A_FAILURE = A.BOLD + A.FG_RED
-            C.A_WARNING = A.FG_RED
-            C.A_PROMPT_PATH = A.FG_BLUE
-            C.A_PROMPT_SUF = A.BOLD
-            C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA
-            C.A_FILE = A.FG_BLACK
-
-    def _bridge_connected(self):
-        self.parser = argparse.ArgumentParser(
-            formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION)
-        self._make_parents()
-        self.add_parser_options()
-        self.subparsers = self.parser.add_subparsers(
-            title=_('Available commands'), dest='command', required=True)
-
-        # progress attributes
-        self._progress_id = None # TODO: manage several progress ids
-        self.quit_on_progress_end = True
-
-        # outputs
-        self._outputs = {}
-        for type_ in C.OUTPUT_TYPES:
-            self._outputs[type_] = OrderedDict()
-        self.default_output = {}
-
-        self.own_jid = None  # must be filled at runtime if needed
-
-    @property
-    def progress_id(self):
-        return self._progress_id
-
-    async def set_progress_id(self, progress_id):
-        # because we use async, we need an explicit setter
-        self._progress_id = progress_id
-        await self.replay_cache('progress_ids_cache')
-
-    @property
-    def watch_progress(self):
-        try:
-            self.pbar
-        except AttributeError:
-            return False
-        else:
-            return True
-
-    @watch_progress.setter
-    def watch_progress(self, watch_progress):
-        if watch_progress:
-            self.pbar = None
-
-    @property
-    def verbosity(self):
-        try:
-            return self.args.verbose
-        except AttributeError:
-            return 0
-
-    async def replay_cache(self, cache_attribute):
-        """Replay cached signals
-
-        @param cache_attribute(str): name of the attribute containing the cache
-            if the attribute doesn't exist, there is no cache and the call is ignored
-            else the cache must be a list of tuples containing the replay callback as
-            first item, then the arguments to use
-        """
-        try:
-            cache = getattr(self, cache_attribute)
-        except AttributeError:
-            pass
-        else:
-            for cache_data in cache:
-                await cache_data[0](*cache_data[1:])
-
-    def disp(self, msg, verbosity=0, error=False, end='\n'):
-        """Print a message to user
-
-        @param msg(unicode): message to print
-        @param verbosity(int): minimal verbosity to display the message
-        @param error(bool): if True, print to stderr instead of stdout
-        @param end(str): string appended after the last value, default a newline
-        """
-        if self.verbosity >= verbosity:
-            if error:
-                print(msg, end=end, file=sys.stderr)
-            else:
-                print(msg, end=end)
-
-    async def output(self, type_, name, extra_outputs, data):
-        if name in extra_outputs:
-            method = extra_outputs[name]
-        else:
-            method = self._outputs[type_][name]['callback']
-
-        ret = method(data)
-        if inspect.isawaitable(ret):
-            await ret
-
-    def add_on_quit_callback(self, callback, *args, **kwargs):
-        """Add a callback which will be called on quit command
-
-        @param callback(callback): method to call
-        """
-        self._onQuitCallbacks.append((callback, args, kwargs))
-
-    def get_output_choices(self, output_type):
-        """Return valid output filters for output_type
-
-        @param output_type: True for default,
-            else can be any registered type
-        """
-        return list(self._outputs[output_type].keys())
-
-    def _make_parents(self):
-        self.parents = {}
-
-        # we have a special case here as the start-session option is present only if
-        # connection is not needed, so we create two similar parents, one with the
-        # option, the other one without it
-        for parent_name in ('profile', 'profile_session'):
-            parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False)
-            parent.add_argument(
-                "-p", "--profile", action="store", type=str, default='@DEFAULT@',
-                help=_("Use PROFILE profile key (default: %(default)s)"))
-            parent.add_argument(
-                "--pwd", action="store", metavar='PASSWORD',
-                help=_("Password used to connect profile, if necessary"))
-
-        profile_parent, profile_session_parent = (self.parents['profile'],
-                                                  self.parents['profile_session'])
-
-        connect_short, connect_long, connect_action, connect_help = (
-            "-c", "--connect", "store_true",
-            _("Connect the profile before doing anything else")
-        )
-        profile_parent.add_argument(
-            connect_short, connect_long, action=connect_action, help=connect_help)
-
-        profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group()
-        profile_session_connect_group.add_argument(
-            connect_short, connect_long, action=connect_action, help=connect_help)
-        profile_session_connect_group.add_argument(
-            "--start-session", action="store_true",
-            help=_("Start a profile session without connecting"))
-
-        progress_parent = self.parents['progress'] = argparse.ArgumentParser(
-            add_help=False)
-        if progressbar:
-            progress_parent.add_argument(
-                "-P", "--progress", action="store_true", help=_("Show progress bar"))
-
-        verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False)
-        verbose_parent.add_argument(
-            '--verbose', '-v', action='count', default=0,
-            help=_("Add a verbosity level (can be used multiple times)"))
-
-        quiet_parent = self.parents['quiet'] = argparse.ArgumentParser(add_help=False)
-        quiet_parent.add_argument(
-            '--quiet', '-q', action='store_true',
-            help=_("be quiet (only output machine readable data)"))
-
-        draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False)
-        draft_group = draft_parent.add_argument_group(_('draft handling'))
-        draft_group.add_argument(
-            "-D", "--current", action="store_true", help=_("load current draft"))
-        draft_group.add_argument(
-            "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve"))
-
-
-    def make_pubsub_group(self, flags, defaults):
-        """Generate pubsub options according to flags
-
-        @param flags(iterable[unicode]): see [CommandBase.__init__]
-        @param defaults(dict[unicode, unicode]): help text for default value
-            key can be "service" or "node"
-            value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT
-        @return (ArgumentParser): parser to add
-        """
-        flags = misc.FlagsHandler(flags)
-        parent = argparse.ArgumentParser(add_help=False)
-        pubsub_group = parent.add_argument_group('pubsub')
-        pubsub_group.add_argument("-u", "--pubsub-url",
-                                  help=_("Pubsub URL (xmpp or http)"))
-
-        service_help = _("JID of the PubSub service")
-        if not flags.service:
-            default = defaults.pop('service', _('PEP service'))
-            if default is not None:
-                service_help += _(" (DEFAULT: {default})".format(default=default))
-        pubsub_group.add_argument("-s", "--service", default='',
-                                  help=service_help)
-
-        node_help = _("node to request")
-        if not flags.node:
-            default = defaults.pop('node', _('standard node'))
-            if default is not None:
-                node_help += _(" (DEFAULT: {default})".format(default=default))
-        pubsub_group.add_argument("-n", "--node", default='', help=node_help)
-
-        if flags.single_item:
-            item_help = ("item to retrieve")
-            if not flags.item:
-                default = defaults.pop('item', _('last item'))
-                if default is not None:
-                    item_help += _(" (DEFAULT: {default})".format(default=default))
-            pubsub_group.add_argument("-i", "--item", default='',
-                                      help=item_help)
-            pubsub_group.add_argument(
-                "-L", "--last-item", action='store_true', help=_('retrieve last item'))
-        elif flags.multi_items:
-            # mutiple items, this activate several features: max-items, RSM, MAM
-            # and Orbder-by
-            pubsub_group.add_argument(
-                "-i", "--item", action='append', dest='items', default=[],
-                help=_("items to retrieve (DEFAULT: all)"))
-            if not flags.no_max:
-                max_group = pubsub_group.add_mutually_exclusive_group()
-                # XXX: defaut value for --max-items or --max is set in parse_pubsub_args
-                max_group.add_argument(
-                    "-M", "--max-items", dest="max", type=int,
-                    help=_("maximum number of items to get ({no_limit} to get all items)"
-                           .format(no_limit=C.NO_LIMIT)))
-                # FIXME: it could be possible to no duplicate max (between pubsub
-                #        max-items and RSM max)should not be duplicated, RSM could be
-                #        used when available and pubsub max otherwise
-                max_group.add_argument(
-                    "-m", "--max", dest="rsm_max", type=int,
-                    help=_("maximum number of items to get per page (DEFAULT: 10)"))
-
-            # RSM
-
-            rsm_page_group = pubsub_group.add_mutually_exclusive_group()
-            rsm_page_group.add_argument(
-                "-a", "--after", dest="rsm_after",
-                help=_("find page after this item"), metavar='ITEM_ID')
-            rsm_page_group.add_argument(
-                "-b", "--before", dest="rsm_before",
-                help=_("find page before this item"), metavar='ITEM_ID')
-            rsm_page_group.add_argument(
-                "--index", dest="rsm_index", type=int,
-                help=_("index of the first item to retrieve"))
-
-
-            # MAM
-
-            pubsub_group.add_argument(
-                "-f", "--filter", dest='mam_filters', nargs=2,
-                action='append', default=[], help=_("MAM filters to use"),
-                metavar=("FILTER_NAME", "VALUE")
-            )
-
-            # Order-By
-
-            # TODO: order-by should be a list to handle several levels of ordering
-            #       but this is not yet done in SàT (and not really useful with
-            #       current specifications, as only "creation" and "modification" are
-            #       available)
-            pubsub_group.add_argument(
-                "-o", "--order-by", choices=[C.ORDER_BY_CREATION,
-                                             C.ORDER_BY_MODIFICATION],
-                help=_("how items should be ordered"))
-
-        if flags[C.CACHE]:
-            pubsub_group.add_argument(
-                "-C", "--no-cache", dest="use_cache", action='store_false',
-                help=_("don't use Pubsub cache")
-            )
-
-        if not flags.all_used:
-            raise exceptions.InternalError('unknown flags: {flags}'.format(
-                flags=', '.join(flags.unused)))
-        if defaults:
-            raise exceptions.InternalError(f'unused defaults: {defaults}')
-
-        return parent
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            '--version',
-            action='version',
-            version=("{name} {version} {copyleft}".format(
-                name = C.APP_NAME,
-                version = self.version,
-                copyleft = COPYLEFT))
-        )
-
-    def register_output(self, type_, name, callback, description="", default=False):
-        if type_ not in C.OUTPUT_TYPES:
-            log.error("Invalid output type {}".format(type_))
-            return
-        self._outputs[type_][name] = {'callback': callback,
-                                      'description': description
-                                     }
-        if default:
-            if type_ in self.default_output:
-                self.disp(
-                    _('there is already a default output for {type}, ignoring new one')
-                    .format(type=type_)
-                )
-            else:
-                self.default_output[type_] = name
-
-
-    def parse_output_options(self):
-        options = self.command.args.output_opts
-        options_dict = {}
-        for option in options:
-            try:
-                key, value = option.split('=', 1)
-            except ValueError:
-                key, value = option, None
-            options_dict[key.strip()] = value.strip() if value is not None else None
-        return options_dict
-
-    def check_output_options(self, accepted_set, options):
-        if not accepted_set.issuperset(options):
-            self.disp(
-                _("The following output options are invalid: {invalid_options}").format(
-                invalid_options = ', '.join(set(options).difference(accepted_set))),
-                error=True)
-            self.quit(C.EXIT_BAD_ARG)
-
-    def import_plugins(self):
-        """Automaticaly import commands and outputs in jp
-
-        looks from modules names cmd_*.py in jp path and import them
-        """
-        path = os.path.dirname(libervia.frontends.jp.__file__)
-        # XXX: outputs must be imported before commands as they are used for arguments
-        for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'),
-                               (C.PLUGIN_CMD, 'cmd_*.py')):
-            modules = (
-                os.path.splitext(module)[0]
-                for module in map(os.path.basename, iglob(os.path.join(path, pattern))))
-            for module_name in modules:
-                module_path = "libervia.frontends.jp." + module_name
-                try:
-                    module = import_module(module_path)
-                    self.import_plugin_module(module, type_)
-                except ImportError as e:
-                    self.disp(
-                        _("Can't import {module_path} plugin, ignoring it: {e}")
-                        .format(module_path=module_path, e=e),
-                        error=True)
-                except exceptions.CancelError:
-                    continue
-                except exceptions.MissingModule as e:
-                    self.disp(_("Missing module for plugin {name}: {missing}".format(
-                        name = module_path,
-                        missing = e)), error=True)
-
-
-    def import_plugin_module(self, module, type_):
-        """add commands or outpus from a module to jp
-
-        @param module: module containing commands or outputs
-        @param type_(str): one of C_PLUGIN_*
-        """
-        try:
-            class_names =  getattr(module, '__{}__'.format(type_))
-        except AttributeError:
-            log.disp(
-                _("Invalid plugin module [{type}] {module}")
-                .format(type=type_, module=module),
-                error=True)
-            raise ImportError
-        else:
-            for class_name in class_names:
-                cls = getattr(module, class_name)
-                cls(self)
-
-    def get_xmpp_uri_from_http(self, http_url):
-        """parse HTML page at http(s) URL, and looks for xmpp: uri"""
-        if http_url.startswith('https'):
-            scheme = 'https'
-        elif http_url.startswith('http'):
-            scheme = 'http'
-        else:
-            raise exceptions.InternalError('An HTTP scheme is expected in this method')
-        self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1)
-        # HTTP URL, we try to find xmpp: links
-        try:
-            from lxml import etree
-        except ImportError:
-            self.disp(
-                "lxml module must be installed to use http(s) scheme, please install it "
-                "with \"pip install lxml\"",
-                error=True)
-            self.quit(1)
-        import urllib.request, urllib.error, urllib.parse
-        parser = etree.HTMLParser()
-        try:
-            root = etree.parse(urllib.request.urlopen(http_url), parser)
-        except etree.XMLSyntaxError as e:
-            self.disp(_("Can't parse HTML page : {msg}").format(msg=e))
-            links = []
-        else:
-            links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
-        if not links:
-            self.disp(
-                _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP '
-                  'PubSub node/item'),
-                error=True)
-            self.quit(1)
-        xmpp_uri = links[0].get('href')
-        return xmpp_uri
-
-    def parse_pubsub_args(self):
-        if self.args.pubsub_url is not None:
-            url = self.args.pubsub_url
-
-            if url.startswith('http'):
-                # http(s) URL, we try to retrieve xmpp one from there
-                url = self.get_xmpp_uri_from_http(url)
-
-            try:
-                uri_data = uri.parse_xmpp_uri(url)
-            except ValueError:
-                self.parser.error(_('invalid XMPP URL: {url}').format(url=url))
-            else:
-                if uri_data['type'] == 'pubsub':
-                    # URL is alright, we only set data not already set by other options
-                    if not self.args.service:
-                        self.args.service = uri_data['path']
-                    if not self.args.node:
-                        self.args.node = uri_data['node']
-                    uri_item = uri_data.get('item')
-                    if uri_item:
-                        # there is an item in URI
-                        # we use it only if item is not already set
-                        # and item_last is not used either
-                        try:
-                            item = self.args.item
-                        except AttributeError:
-                            try:
-                                items = self.args.items
-                            except AttributeError:
-                                self.disp(
-                                    _("item specified in URL but not needed in command, "
-                                      "ignoring it"),
-                                    error=True)
-                            else:
-                                if not items:
-                                    self.args.items = [uri_item]
-                        else:
-                            if not item:
-                                try:
-                                    item_last = self.args.item_last
-                                except AttributeError:
-                                    item_last = False
-                                if not item_last:
-                                    self.args.item = uri_item
-                else:
-                    self.parser.error(
-                        _('XMPP URL is not a pubsub one: {url}').format(url=url)
-                    )
-        flags = self.args._cmd._pubsub_flags
-        # we check required arguments here instead of using add_arguments' required option
-        # because the required argument can be set in URL
-        if C.SERVICE in flags and not self.args.service:
-            self.parser.error(_("argument -s/--service is required"))
-        if C.NODE in flags and not self.args.node:
-            self.parser.error(_("argument -n/--node is required"))
-        if C.ITEM in flags and not self.args.item:
-            self.parser.error(_("argument -i/--item is required"))
-
-        # FIXME: mutually groups can't be nested in a group and don't support title
-        #        so we check conflict here. This may be fixed in Python 3, to be checked
-        try:
-            if self.args.item and self.args.item_last:
-                self.parser.error(
-                    _("--item and --item-last can't be used at the same time"))
-        except AttributeError:
-            pass
-
-        try:
-            max_items = self.args.max
-            rsm_max = self.args.rsm_max
-        except AttributeError:
-            pass
-        else:
-            # we need to set a default value for max, but we need to know if we want
-            # to use pubsub's max or RSM's max. The later is used if any RSM or MAM
-            # argument is set
-            if max_items is None and rsm_max is None:
-                to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before',
-                            'rsm_index')
-                if any((getattr(self.args, name) for name in to_check)):
-                    # we use RSM
-                    self.args.rsm_max = 10
-                else:
-                    # we use pubsub without RSM
-                    self.args.max = 10
-            if self.args.max is None:
-                self.args.max = C.NO_LIMIT
-
-    async def main(self, args, namespace):
-        try:
-            await self.bridge.bridge_connect()
-        except Exception as e:
-            if isinstance(e, exceptions.BridgeExceptionNoService):
-                print(
-                    _("Can't connect to Libervia backend, are you sure that it's "
-                      "launched ?")
-                )
-                self.quit(C.EXIT_BACKEND_NOT_FOUND, raise_exc=False)
-            elif isinstance(e, exceptions.BridgeInitError):
-                print(_("Can't init bridge"))
-                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
-            else:
-                print(
-                    _("Error while initialising bridge: {e}").format(e=e)
-                )
-                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
-            return
-        await self.bridge.ready_get()
-        self.version = await self.bridge.version_get()
-        self._bridge_connected()
-        self.import_plugins()
-        try:
-            self.args = self.parser.parse_args(args, namespace=None)
-            if self.args._cmd._use_pubsub:
-                self.parse_pubsub_args()
-            await self.args._cmd.run()
-        except SystemExit as e:
-            self.quit(e.code, raise_exc=False)
-            return
-        except QuitException:
-            return
-
-    def _run(self, args=None, namespace=None):
-        self.loop = JPLoop()
-        self.loop.run(self, args, namespace)
-
-    @classmethod
-    def run(cls):
-        cls()._run()
-
-    def _read_stdin(self, stdin_fut):
-        """Callback called by ainput to read stdin"""
-        line = sys.stdin.readline()
-        if line:
-            stdin_fut.set_result(line.rstrip(os.linesep))
-        else:
-            stdin_fut.set_exception(EOFError())
-
-    async def ainput(self, msg=''):
-        """Asynchronous version of buildin "input" function"""
-        self.disp(msg, end=' ')
-        sys.stdout.flush()
-        loop = asyncio.get_running_loop()
-        stdin_fut = loop.create_future()
-        loop.add_reader(sys.stdin, self._read_stdin, stdin_fut)
-        return await stdin_fut
-
-    async def confirm(self, message):
-        """Request user to confirm action, return answer as boolean"""
-        res = await self.ainput(f"{message} (y/N)? ")
-        return res in ("y", "Y")
-
-    async def confirm_or_quit(self, message, cancel_message=_("action cancelled by user")):
-        """Request user to confirm action, and quit if he doesn't"""
-        confirmed = await self.confirm(message)
-        if not confirmed:
-            self.disp(cancel_message)
-            self.quit(C.EXIT_USER_CANCELLED)
-
-    def quit_from_signal(self, exit_code=0):
-        r"""Same as self.quit, but from a signal handler
-
-        /!\: return must be used after calling this method !
-        """
-        # XXX: python-dbus will show a traceback if we exit in a signal handler
-        # so we use this little timeout trick to avoid it
-        self.loop.call_later(0, self.quit, exit_code)
-
-    def quit(self, exit_code=0, raise_exc=True):
-        """Terminate the execution with specified exit_code
-
-        This will stop the loop.
-        @param exit_code(int): code to return when quitting the program
-        @param raise_exp(boolean): if True raise a QuitException to stop code execution
-            The default value should be used most of time.
-        """
-        # first the onQuitCallbacks
-        try:
-            callbacks_list = self._onQuitCallbacks
-        except AttributeError:
-            pass
-        else:
-            for callback, args, kwargs in callbacks_list:
-                callback(*args, **kwargs)
-
-        self.loop.quit(exit_code)
-        if raise_exc:
-            raise QuitException
-
-    async def check_jids(self, jids):
-        """Check jids validity, transform roster name to corresponding jids
-
-        @param profile: profile name
-        @param jids: list of jids
-        @return: List of jids
-
-        """
-        names2jid = {}
-        nodes2jid = {}
-
-        try:
-            contacts = await self.bridge.contacts_get(self.profile)
-        except BridgeException as e:
-            if e.classname == "AttributeError":
-                # we may get an AttributeError if we use a component profile
-                # as components don't have roster
-                contacts = []
-            else:
-                raise e
-
-        for contact in contacts:
-            jid_s, attr, groups = contact
-            _jid = JID(jid_s)
-            try:
-                names2jid[attr["name"].lower()] = jid_s
-            except KeyError:
-                pass
-
-            if _jid.node:
-                nodes2jid[_jid.node.lower()] = jid_s
-
-        def expand_jid(jid):
-            _jid = jid.lower()
-            if _jid in names2jid:
-                expanded = names2jid[_jid]
-            elif _jid in nodes2jid:
-                expanded = nodes2jid[_jid]
-            else:
-                expanded = jid
-            return expanded
-
-        def check(jid):
-            if not jid.is_valid:
-                log.error (_("%s is not a valid JID !"), jid)
-                self.quit(1)
-
-        dest_jids=[]
-        try:
-            for i in range(len(jids)):
-                dest_jids.append(expand_jid(jids[i]))
-                check(dest_jids[i])
-        except AttributeError:
-            pass
-
-        return dest_jids
-
-    async def a_pwd_input(self, msg=''):
-        """Like ainput but with echo disabled (useful for passwords)"""
-        # we disable echo, code adapted from getpass standard module which has been
-        # written by Piers Lauder (original), Guido van Rossum (Windows support and
-        # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks
-        # to them (and for all the amazing work on Python).
-        stdin_fd = sys.stdin.fileno()
-        old = termios.tcgetattr(sys.stdin)
-        new = old[:]
-        new[3] &= ~termios.ECHO
-        tcsetattr_flags = termios.TCSAFLUSH
-        if hasattr(termios, 'TCSASOFT'):
-            tcsetattr_flags |= termios.TCSASOFT
-        try:
-            termios.tcsetattr(stdin_fd, tcsetattr_flags, new)
-            pwd = await self.ainput(msg=msg)
-        finally:
-            termios.tcsetattr(stdin_fd, tcsetattr_flags, old)
-            sys.stderr.flush()
-        self.disp('')
-        return pwd
-
-    async def connect_or_prompt(self, method, err_msg=None):
-        """Try to connect/start profile session and prompt for password if needed
-
-        @param method(callable): bridge method to either connect or start profile session
-            It will be called with password as sole argument, use lambda to do the call
-            properly
-        @param err_msg(str): message to show if connection fail
-        """
-        password = self.args.pwd
-        while True:
-            try:
-                await method(password or '')
-            except Exception as e:
-                if ((isinstance(e, BridgeException)
-                     and e.classname == 'PasswordError'
-                     and self.args.pwd is None)):
-                    if password is not None:
-                        self.disp(A.color(C.A_WARNING, _("invalid password")))
-                    password = await self.a_pwd_input(
-                        _("please enter profile password:"))
-                else:
-                    self.disp(err_msg.format(profile=self.profile, e=e), error=True)
-                    self.quit(C.EXIT_ERROR)
-            else:
-                break
-
-    async def connect_profile(self):
-        """Check if the profile is connected and do it if requested
-
-        @exit: - 1 when profile is not connected and --connect is not set
-               - 1 when the profile doesn't exists
-               - 1 when there is a connection error
-        """
-        # FIXME: need better exit codes
-
-        self.profile = await self.bridge.profile_name_get(self.args.profile)
-
-        if not self.profile:
-            log.error(
-                _("The profile [{profile}] doesn't exist")
-                .format(profile=self.args.profile)
-            )
-            self.quit(C.EXIT_ERROR)
-
-        try:
-            start_session = self.args.start_session
-        except AttributeError:
-            pass
-        else:
-            if start_session:
-                await self.connect_or_prompt(
-                    lambda pwd: self.bridge.profile_start_session(pwd, self.profile),
-                    err_msg="Can't start {profile}'s session: {e}"
-                )
-                return
-            elif not await self.bridge.profile_is_session_started(self.profile):
-                if not self.args.connect:
-                    self.disp(_(
-                        "Session for [{profile}] is not started, please start it "
-                        "before using jp, or use either --start-session or --connect "
-                        "option"
-                        .format(profile=self.profile)
-                    ), error=True)
-                    self.quit(1)
-            elif not getattr(self.args, "connect", False):
-                return
-
-
-        if not hasattr(self.args, 'connect'):
-            # a profile can be present without connect option (e.g. on profile
-            # creation/deletion)
-            return
-        elif self.args.connect is True:  # if connection is asked, we connect the profile
-            await self.connect_or_prompt(
-                lambda pwd: self.bridge.connect(self.profile, pwd, {}),
-                err_msg = 'Can\'t connect profile "{profile!s}": {e}'
-            )
-            return
-        else:
-            if not await self.bridge.is_connected(self.profile):
-                log.error(
-                    _("Profile [{profile}] is not connected, please connect it "
-                      "before using jp, or use --connect option")
-                    .format(profile=self.profile)
-                )
-                self.quit(1)
-
-    async def get_full_jid(self, param_jid):
-        """Return the full jid if possible (add main resource when find a bare jid)"""
-        # TODO: to be removed, bare jid should work with all commands, notably for file
-        #   as backend now handle jingles message initiation
-        _jid = JID(param_jid)
-        if not _jid.resource:
-            #if the resource is not given, we try to add the main resource
-            main_resource = await self.bridge.main_resource_get(param_jid, self.profile)
-            if main_resource:
-                return f"{_jid.bare}/{main_resource}"
-        return param_jid
-
-    async def get_profile_jid(self):
-        """Retrieve current profile bare JID if possible"""
-        full_jid = await self.bridge.param_get_a_async(
-            "JabberID", "Connection", profile_key=self.profile
-        )
-        return full_jid.rsplit("/", 1)[0]
-
-
-class CommandBase:
-
-    def __init__(
-        self,
-        host: LiberviaCli,
-        name: str,
-        use_profile: bool = True,
-        use_output: Union[bool, str] = False,
-        extra_outputs: Optional[dict] = None,
-        need_connect: Optional[bool] = None,
-        help: Optional[str] = None,
-        **kwargs
-    ):
-        """Initialise CommandBase
-
-        @param host: Jp instance
-        @param name: name of the new command
-        @param use_profile: if True, add profile selection/connection commands
-        @param use_output: if not False, add --output option
-        @param extra_outputs: list of command specific outputs:
-            key is output name ("default" to use as main output)
-            value is a callable which will format the output (data will be used as only
-            argument)
-            if a key already exists with normal outputs, the extra one will be used
-        @param need_connect: True if profile connection is needed
-            False else (profile session must still be started)
-            None to set auto value (i.e. True if use_profile is set)
-            Can't be set if use_profile is False
-        @param help: help message to display
-        @param **kwargs: args passed to ArgumentParser
-            use_* are handled directly, they can be:
-            - use_progress(bool): if True, add progress bar activation option
-                progress* signals will be handled
-            - use_verbose(bool): if True, add verbosity option
-            - use_pubsub(bool): if True, add pubsub options
-                mandatory arguments are controlled by pubsub_req
-            - use_draft(bool): if True, add draft handling options
-            ** other arguments **
-            - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options,
-              can be:
-                C.SERVICE: service is required
-                C.NODE: node is required
-                C.ITEM: item is required
-                C.SINGLE_ITEM: only one item is allowed
-        """
-        try: # If we have subcommands, host is a CommandBase and we need to use host.host
-            self.host = host.host
-        except AttributeError:
-            self.host = host
-
-        # --profile option
-        parents = kwargs.setdefault('parents', set())
-        if use_profile:
-            # self.host.parents['profile'] is an ArgumentParser with profile connection
-            # arguments
-            if need_connect is None:
-                need_connect = True
-            parents.add(
-                self.host.parents['profile' if need_connect else 'profile_session'])
-        else:
-            assert need_connect is None
-        self.need_connect = need_connect
-        # from this point, self.need_connect is None if connection is not needed at all
-        # False if session starting is needed, and True if full connection is needed
-
-        # --output option
-        if use_output:
-            if extra_outputs is None:
-                extra_outputs = {}
-            self.extra_outputs = extra_outputs
-            if use_output == True:
-                use_output = C.OUTPUT_TEXT
-            assert use_output in C.OUTPUT_TYPES
-            self._output_type = use_output
-            output_parent = argparse.ArgumentParser(add_help=False)
-            choices = set(self.host.get_output_choices(use_output))
-            choices.update(extra_outputs)
-            if not choices:
-                raise exceptions.InternalError(
-                    "No choice found for {} output type".format(use_output))
-            try:
-                default = self.host.default_output[use_output]
-            except KeyError:
-                if 'default' in choices:
-                    default = 'default'
-                elif 'simple' in choices:
-                    default = 'simple'
-                else:
-                    default = list(choices)[0]
-            output_parent.add_argument(
-                '--output', '-O', choices=sorted(choices), default=default,
-                help=_("select output format (default: {})".format(default)))
-            output_parent.add_argument(
-                '--output-option', '--oo', action="append", dest='output_opts',
-                default=[], help=_("output specific option"))
-            parents.add(output_parent)
-        else:
-            assert extra_outputs is None
-
-        self._use_pubsub = kwargs.pop('use_pubsub', False)
-        if self._use_pubsub:
-            flags = kwargs.pop('pubsub_flags', [])
-            defaults = kwargs.pop('pubsub_defaults', {})
-            parents.add(self.host.make_pubsub_group(flags, defaults))
-            self._pubsub_flags = flags
-
-        # other common options
-        use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')}
-        for param, do_use in use_opts.items():
-            opt=param[4:] # if param is use_verbose, opt is verbose
-            if opt not in self.host.parents:
-                raise exceptions.InternalError("Unknown parent option {}".format(opt))
-            del kwargs[param]
-            if do_use:
-                parents.add(self.host.parents[opt])
-
-        self.parser = host.subparsers.add_parser(name, help=help, **kwargs)
-        if hasattr(self, "subcommands"):
-            self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True)
-        else:
-            self.parser.set_defaults(_cmd=self)
-        self.add_parser_options()
-
-    @property
-    def sat_conf(self):
-        return self.host.sat_conf
-
-    @property
-    def args(self):
-        return self.host.args
-
-    @property
-    def profile(self):
-        return self.host.profile
-
-    @property
-    def verbosity(self):
-        return self.host.verbosity
-
-    @property
-    def progress_id(self):
-        return self.host.progress_id
-
-    async def set_progress_id(self, progress_id):
-        return await self.host.set_progress_id(progress_id)
-
-    async def progress_started_handler(self, uid, metadata, profile):
-        if profile != self.profile:
-            return
-        if self.progress_id is None:
-            # the progress started message can be received before the id
-            # so we keep progress_started signals in cache to replay they
-            # when the progress_id is received
-            cache_data = (self.progress_started_handler, uid, metadata, profile)
-            try:
-                cache = self.host.progress_ids_cache
-            except AttributeError:
-                cache = self.host.progress_ids_cache = []
-            cache.append(cache_data)
-        else:
-            if self.host.watch_progress and uid == self.progress_id:
-                await self.on_progress_started(metadata)
-                while True:
-                    await asyncio.sleep(PROGRESS_DELAY)
-                    cont = await self.progress_update()
-                    if not cont:
-                        break
-
-    async def progress_finished_handler(self, uid, metadata, profile):
-        if profile != self.profile:
-            return
-        if uid == self.progress_id:
-            try:
-                self.host.pbar.finish()
-            except AttributeError:
-                pass
-            await self.on_progress_finished(metadata)
-            if self.host.quit_on_progress_end:
-                self.host.quit_from_signal()
-
-    async def progress_error_handler(self, uid, message, profile):
-        if profile != self.profile:
-            return
-        if uid == self.progress_id:
-            if self.args.progress:
-                self.disp('') # progress is not finished, so we skip a line
-            if self.host.quit_on_progress_end:
-                await self.on_progress_error(message)
-                self.host.quit_from_signal(C.EXIT_ERROR)
-
-    async def progress_update(self):
-        """This method is continualy called to update the progress bar
-
-        @return (bool): False to stop being called
-        """
-        data = await self.host.bridge.progress_get(self.progress_id, self.profile)
-        if data:
-            try:
-                size = data['size']
-            except KeyError:
-                self.disp(_("file size is not known, we can't show a progress bar"), 1,
-                          error=True)
-                return False
-            if self.host.pbar is None:
-                #first answer, we must construct the bar
-
-                # if the instance has a pbar_template attribute, it is used has model,
-                # else default one is used
-                # template is a list of part, where part can be either a str to show directly
-                # or a list where first argument is a name of a progressbar widget, and others
-                # are used as widget arguments
-                try:
-                    template = self.pbar_template
-                except AttributeError:
-                    template = [
-                        _("Progress: "), ["Percentage"], " ", ["Bar"], " ",
-                        ["FileTransferSpeed"], " ", ["ETA"]
-                    ]
-
-                widgets = []
-                for part in template:
-                    if isinstance(part, str):
-                        widgets.append(part)
-                    else:
-                        widget = getattr(progressbar, part.pop(0))
-                        widgets.append(widget(*part))
-
-                self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets)
-                self.host.pbar.start()
-
-            self.host.pbar.update(int(data['position']))
-
-        elif self.host.pbar is not None:
-            return False
-
-        await self.on_progress_update(data)
-
-        return True
-
-    async def on_progress_started(self, metadata):
-        """Called when progress has just started
-
-        can be overidden by a command
-        @param metadata(dict): metadata as sent by bridge.progress_started
-        """
-        self.disp(_("Operation started"), 2)
-
-    async def on_progress_update(self, metadata):
-        """Method called on each progress updata
-
-        can be overidden by a command to handle progress metadata
-        @para metadata(dict): metadata as returned by bridge.progress_get
-        """
-        pass
-
-    async def on_progress_finished(self, metadata):
-        """Called when progress has just finished
-
-        can be overidden by a command
-        @param metadata(dict): metadata as sent by bridge.progress_finished
-        """
-        self.disp(_("Operation successfully finished"), 2)
-
-    async def on_progress_error(self, e):
-        """Called when a progress failed
-
-        @param error_msg(unicode): error message as sent by bridge.progress_error
-        """
-        self.disp(_("Error while doing operation: {e}").format(e=e), error=True)
-
-    def disp(self, msg, verbosity=0, error=False, end='\n'):
-        return self.host.disp(msg, verbosity, error, end)
-
-    def output(self, data):
-        try:
-            output_type = self._output_type
-        except AttributeError:
-            raise exceptions.InternalError(
-                _('trying to use output when use_output has not been set'))
-        return self.host.output(output_type, self.args.output, self.extra_outputs, data)
-
-    def get_pubsub_extra(self, extra: Optional[dict] = None) -> str:
-        """Helper method to compute extra data from pubsub arguments
-
-        @param extra: base extra dict, or None to generate a new one
-        @return: serialised dict which can be used directly in the bridge for pubsub
-        """
-        if extra is None:
-            extra = {}
-        else:
-            intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys()))
-            if intersection:
-                raise exceptions.ConflictError(
-                    "given extra dict has conflicting keys with pubsub keys "
-                    "{intersection}".format(intersection=intersection))
-
-        # RSM
-
-        for attribute in ('max', 'after', 'before', 'index'):
-            key = 'rsm_' + attribute
-            if key in extra:
-                raise exceptions.ConflictError(
-                    "This key already exists in extra: u{key}".format(key=key))
-            value = getattr(self.args, key, None)
-            if value is not None:
-                extra[key] = str(value)
-
-        # MAM
-
-        if hasattr(self.args, 'mam_filters'):
-            for key, value in self.args.mam_filters:
-                key = 'filter_' + key
-                if key in extra:
-                    raise exceptions.ConflictError(
-                        "This key already exists in extra: u{key}".format(key=key))
-                extra[key] = value
-
-        # Order-By
-
-        try:
-            order_by = self.args.order_by
-        except AttributeError:
-            pass
-        else:
-            if order_by is not None:
-                extra[C.KEY_ORDER_BY] = self.args.order_by
-
-        # Cache
-        try:
-            use_cache = self.args.use_cache
-        except AttributeError:
-            pass
-        else:
-            if not use_cache:
-                extra[C.KEY_USE_CACHE] = use_cache
-
-        return data_format.serialise(extra)
-
-    def add_parser_options(self):
-        try:
-            subcommands = self.subcommands
-        except AttributeError:
-            # We don't have subcommands, the class need to implements add_parser_options
-            raise NotImplementedError
-
-        # now we add subcommands to ourself
-        for cls in subcommands:
-            cls(self)
-
-    def override_pubsub_flags(self, new_flags: Set[str]) -> None:
-        """Replace pubsub_flags given in __init__
-
-        useful when a command is extending an other command (e.g. blog command which does
-        the same as pubsub command, but with a default node)
-        """
-        self._pubsub_flags = new_flags
-
-    async def run(self):
-        """this method is called when a command is actually run
-
-        It set stuff like progression callbacks and profile connection
-        You should not overide this method: you should call self.start instead
-        """
-        # we keep a reference to run command, it may be useful e.g. for outputs
-        self.host.command = self
-
-        try:
-            show_progress = self.args.progress
-        except AttributeError:
-            # the command doesn't use progress bar
-            pass
-        else:
-            if show_progress:
-                self.host.watch_progress = True
-            # we need to register the following signal even if we don't display the
-            # progress bar
-            self.host.bridge.register_signal(
-                "progress_started", self.progress_started_handler)
-            self.host.bridge.register_signal(
-                "progress_finished", self.progress_finished_handler)
-            self.host.bridge.register_signal(
-                "progress_error", self.progress_error_handler)
-
-        if self.need_connect is not None:
-            await self.host.connect_profile()
-        await self.start()
-
-    async def start(self):
-        """This is the starting point of the command, this method must be overriden
-
-        at this point, profile are connected if needed
-        """
-        raise NotImplementedError
-
-
-class CommandAnswering(CommandBase):
-    """Specialised commands which answer to specific actions
-
-    to manage action_types answer,
-    """
-    action_callbacks = {} # XXX: set managed action types in a dict here:
-                          # key is the action_type, value is the callable
-                          # which will manage the answer. profile filtering is
-                          # already managed when callback is called
-
-    def __init__(self, *args, **kwargs):
-        super(CommandAnswering, self).__init__(*args, **kwargs)
-
-    async def on_action_new(
-        self,
-        action_data_s: str,
-        action_id: str,
-        security_limit: int,
-        profile: str
-    ) -> None:
-        if profile != self.profile:
-            return
-        action_data = data_format.deserialise(action_data_s)
-        try:
-            action_type = action_data['type']
-        except KeyError:
-            try:
-                xml_ui = action_data["xmlui"]
-            except KeyError:
-                pass
-            else:
-                self.on_xmlui(xml_ui)
-        else:
-            try:
-                callback = self.action_callbacks[action_type]
-            except KeyError:
-                pass
-            else:
-                await callback(action_data, action_id, security_limit, profile)
-
-    def on_xmlui(self, xml_ui):
-        """Display a dialog received from the backend.
-
-        @param xml_ui (unicode): dialog XML representation
-        """
-        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
-        #        should be available in the future
-        # TODO: XMLUI module
-        ui = ET.fromstring(xml_ui.encode('utf-8'))
-        dialog = ui.find("dialog")
-        if dialog is not None:
-            self.disp(dialog.findtext("message"), error=dialog.get("level") == "error")
-
-    async def start_answering(self):
-        """Auto reply to confirmation requests"""
-        self.host.bridge.register_signal("action_new", self.on_action_new)
-        actions = await self.host.bridge.actions_get(self.profile)
-        for action_data_s, action_id, security_limit in actions:
-            await self.on_action_new(action_data_s, action_id, security_limit, self.profile)
--- a/libervia/frontends/jp/cmd_account.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,253 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)"""
-
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.bridge.bridge_frontend import BridgeException
-from libervia.backend.core.log import getLogger
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp import base
-from libervia.frontends.tools import jid
-
-
-log = getLogger(__name__)
-
-__commands__ = ["Account"]
-
-
-class AccountCreate(base.CommandBase):
-    def __init__(self, host):
-        super(AccountCreate, self).__init__(
-            host,
-            "create",
-            use_profile=False,
-            use_verbose=True,
-            help=_("create a XMPP account"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid", help=_("jid to create")
-        )
-        self.parser.add_argument(
-            "password", help=_("password of the account")
-        )
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            help=_(
-                "create a profile to use this account (default: don't create profile)"
-            ),
-        )
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            default="",
-            help=_("email (usage depends of XMPP server)"),
-        )
-        self.parser.add_argument(
-            "-H",
-            "--host",
-            default="",
-            help=_("server host (IP address or domain, default: use localhost)"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--port",
-            type=int,
-            default=0,
-            help=_("server port (default: {port})").format(
-                port=C.XMPP_C2S_PORT
-            ),
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.in_band_account_new(
-                self.args.jid,
-                self.args.password,
-                self.args.email,
-                self.args.host,
-                self.args.port,
-            )
-
-        except BridgeException as e:
-            if e.condition == 'conflict':
-                self.disp(
-                    f"The account {self.args.jid} already exists",
-                    error=True
-                )
-                self.host.quit(C.EXIT_CONFLICT)
-            else:
-                self.disp(
-                    f"can't create account on {self.args.host or 'localhost'!r} with jid "
-                    f"{self.args.jid!r} using In-Band Registration: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-
-        self.disp(_("XMPP account created"), 1)
-
-        if self.args.profile is None:
-            self.host.quit()
-
-
-        self.disp(_("creating profile"), 2)
-        try:
-            await self.host.bridge.profile_create(
-                self.args.profile,
-                self.args.password,
-                "",
-            )
-        except BridgeException as e:
-            if e.condition == 'conflict':
-                self.disp(
-                    f"The profile {self.args.profile} already exists",
-                    error=True
-                )
-                self.host.quit(C.EXIT_CONFLICT)
-            else:
-                self.disp(
-                    _("Can't create profile {profile} to associate with jid "
-                      "{jid}: {e}").format(
-                          profile=self.args.profile,
-                          jid=self.args.jid,
-                          e=e
-                      ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-
-        self.disp(_("profile created"), 1)
-        try:
-            await self.host.bridge.profile_start_session(
-                self.args.password,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't start profile session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        try:
-            await self.host.bridge.param_set(
-                "JabberID",
-                self.args.jid,
-                "Connection",
-                profile_key=self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set JabberID parameter: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        try:
-            await self.host.bridge.param_set(
-                "Password",
-                self.args.password,
-                "Connection",
-                profile_key=self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set Password parameter: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.disp(
-            f"profile {self.args.profile} successfully created and associated to the new "
-            f"account", 1)
-        self.host.quit()
-
-
-class AccountModify(base.CommandBase):
-    def __init__(self, host):
-        super(AccountModify, self).__init__(
-            host, "modify", help=_("change password for XMPP account")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "password", help=_("new XMPP password")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.in_band_password_change(
-                self.args.password,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't change XMPP password: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class AccountDelete(base.CommandBase):
-    def __init__(self, host):
-        super(AccountDelete, self).__init__(
-            host, "delete", help=_("delete a XMPP account")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("delete account without confirmation"),
-        )
-
-    async def start(self):
-        try:
-            jid_str = await self.host.bridge.param_get_a_async(
-                "JabberID",
-                "Connection",
-                profile_key=self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get JID of the profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        jid_ = jid.JID(jid_str)
-        if not self.args.force:
-            message = (
-                f"You are about to delete the XMPP account with jid {jid_!r}\n"
-                f"This is the XMPP account of profile {self.profile!r}\n"
-                f"Are you sure that you want to delete this account?"
-            )
-            await self.host.confirm_or_quit(message, _("Account deletion cancelled"))
-
-        try:
-            await self.host.bridge.in_band_unregister(jid_.domain, self.args.profile)
-        except Exception as e:
-            self.disp(f"can't delete XMPP account with jid {jid_!r}: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class Account(base.CommandBase):
-    subcommands = (AccountCreate, AccountModify, AccountDelete)
-
-    def __init__(self, host):
-        super(Account, self).__init__(
-            host, "account", use_profile=False, help=("XMPP account management")
-        )
--- a/libervia/frontends/jp/cmd_adhoc.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,203 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import xmlui_manager
-
-__commands__ = ["AdHoc"]
-
-FLAG_LOOP = "LOOP"
-MAGIC_BAREJID = "@PROFILE_BAREJID@"
-
-
-class Remote(base.CommandBase):
-    def __init__(self, host):
-        super(Remote, self).__init__(
-            host, "remote", use_verbose=True, help=_("remote control a software")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("software", type=str, help=_("software name"))
-        self.parser.add_argument(
-            "-j",
-            "--jids",
-            nargs="*",
-            default=[],
-            help=_("jids allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "-g",
-            "--groups",
-            nargs="*",
-            default=[],
-            help=_("groups allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "--forbidden-groups",
-            nargs="*",
-            default=[],
-            help=_("groups that are *NOT* allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "--forbidden-jids",
-            nargs="*",
-            default=[],
-            help=_("jids that are *NOT* allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "-l", "--loop", action="store_true", help=_("loop on the commands")
-        )
-
-    async def start(self):
-        name = self.args.software.lower()
-        flags = []
-        magics = {jid for jid in self.args.jids if jid.count("@") > 1}
-        magics.add(MAGIC_BAREJID)
-        jids = set(self.args.jids).difference(magics)
-        if self.args.loop:
-            flags.append(FLAG_LOOP)
-        try:
-            bus_name, methods = await self.host.bridge.ad_hoc_dbus_add_auto(
-                name,
-                list(jids),
-                self.args.groups,
-                magics,
-                self.args.forbidden_jids,
-                self.args.forbidden_groups,
-                flags,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create remote control: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not bus_name:
-                self.disp(_("No bus name found"), 1)
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(_("Bus name found: [%s]" % bus_name), 1)
-                for method in methods:
-                    path, iface, command = method
-                    self.disp(
-                        _("Command found: (path:{path}, iface: {iface}) [{command}]")
-                        .format(path=path, iface=iface, command=command),
-                        1,
-                    )
-                self.host.quit()
-
-
-class Run(base.CommandBase):
-    """Run an Ad-Hoc command"""
-
-    def __init__(self, host):
-        super(Run, self).__init__(
-            host, "run", use_verbose=True, help=_("run an Ad-Hoc command")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            default="",
-            help=_("jid of the service (default: profile's server"),
-        )
-        self.parser.add_argument(
-            "-S",
-            "--submit",
-            action="append_const",
-            const=xmlui_manager.SUBMIT,
-            dest="workflow",
-            help=_("submit form/page"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="workflow",
-            metavar=("KEY", "VALUE"),
-            help=_("field value"),
-        )
-        self.parser.add_argument(
-            "node",
-            nargs="?",
-            default="",
-            help=_("node of the command (default: list commands)"),
-        )
-
-    async def start(self):
-        try:
-            xmlui_raw = await self.host.bridge.ad_hoc_run(
-                self.args.jid,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            xmlui = xmlui_manager.create(self.host, xmlui_raw)
-            workflow = self.args.workflow
-            await xmlui.show(workflow)
-            if not workflow:
-                if xmlui.type == "form":
-                    await xmlui.submit_form()
-            self.host.quit()
-
-
-class List(base.CommandBase):
-    """List Ad-Hoc commands available on a service"""
-
-    def __init__(self, host):
-        super(List, self).__init__(
-            host, "list", use_verbose=True, help=_("list Ad-Hoc commands of a service")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            default="",
-            help=_("jid of the service (default: profile's server)"),
-        )
-
-    async def start(self):
-        try:
-            xmlui_raw = await self.host.bridge.ad_hoc_list(
-                self.args.jid,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            xmlui = xmlui_manager.create(self.host, xmlui_raw)
-            await xmlui.show(read_only=True)
-            self.host.quit()
-
-
-class AdHoc(base.CommandBase):
-    subcommands = (Run, List, Remote)
-
-    def __init__(self, host):
-        super(AdHoc, self).__init__(
-            host, "ad-hoc", use_profile=False, help=_("Ad-hoc commands")
-        )
--- a/libervia/frontends/jp/cmd_application.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,191 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp.constants import Const as C
-
-__commands__ = ["Application"]
-
-
-class List(base.CommandBase):
-    """List available applications"""
-
-    def __init__(self, host):
-        super(List, self).__init__(
-            host, "list", use_profile=False, use_output=C.OUTPUT_LIST,
-            help=_("list available applications")
-        )
-
-    def add_parser_options(self):
-        # FIXME: "extend" would be better here, but it's only available from Python 3.8+
-        #   so we use "append" until minimum version of Python is raised.
-        self.parser.add_argument(
-            "-f",
-            "--filter",
-            dest="filters",
-            action="append",
-            choices=["available", "running"],
-            help=_("show applications with this status"),
-        )
-
-    async def start(self):
-
-        # FIXME: this is only needed because we can't use "extend" in
-        #   add_parser_options, see note there
-        if self.args.filters:
-            self.args.filters = list(set(self.args.filters))
-        else:
-            self.args.filters = ['available']
-
-        try:
-            found_apps = await self.host.bridge.applications_list(self.args.filters)
-        except Exception as e:
-            self.disp(f"can't get applications list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(found_apps)
-            self.host.quit()
-
-
-class Start(base.CommandBase):
-    """Start an application"""
-
-    def __init__(self, host):
-        super(Start, self).__init__(
-            host, "start", use_profile=False, help=_("start an application")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "name",
-            help=_("name of the application to start"),
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.application_start(
-                self.args.name,
-                "",
-            )
-        except Exception as e:
-            self.disp(f"can't start {self.args.name}: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Stop(base.CommandBase):
-
-    def __init__(self, host):
-        super(Stop, self).__init__(
-            host, "stop", use_profile=False, help=_("stop a running application")
-        )
-
-    def add_parser_options(self):
-        id_group = self.parser.add_mutually_exclusive_group(required=True)
-        id_group.add_argument(
-            "name",
-            nargs="?",
-            help=_("name of the application to stop"),
-        )
-        id_group.add_argument(
-            "-i",
-            "--id",
-            help=_("identifier of the instance to stop"),
-        )
-
-    async def start(self):
-        try:
-            if self.args.name is not None:
-                args = [self.args.name, "name"]
-            else:
-                args = [self.args.id, "instance"]
-            await self.host.bridge.application_stop(
-                *args,
-                "",
-            )
-        except Exception as e:
-            if self.args.name is not None:
-                self.disp(
-                    f"can't stop application {self.args.name!r}: {e}", error=True)
-            else:
-                self.disp(
-                    f"can't stop application instance with id {self.args.id!r}: {e}",
-                    error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Exposed(base.CommandBase):
-
-    def __init__(self, host):
-        super(Exposed, self).__init__(
-            host, "exposed", use_profile=False, use_output=C.OUTPUT_DICT,
-            help=_("show data exposed by a running application")
-        )
-
-    def add_parser_options(self):
-        id_group = self.parser.add_mutually_exclusive_group(required=True)
-        id_group.add_argument(
-            "name",
-            nargs="?",
-            help=_("name of the application to check"),
-        )
-        id_group.add_argument(
-            "-i",
-            "--id",
-            help=_("identifier of the instance to check"),
-        )
-
-    async def start(self):
-        try:
-            if self.args.name is not None:
-                args = [self.args.name, "name"]
-            else:
-                args = [self.args.id, "instance"]
-            exposed_data_raw = await self.host.bridge.application_exposed_get(
-                *args,
-                "",
-            )
-        except Exception as e:
-            if self.args.name is not None:
-                self.disp(
-                    f"can't get values exposed from application {self.args.name!r}: {e}",
-                    error=True)
-            else:
-                self.disp(
-                    f"can't values exposed from  application instance with id {self.args.id!r}: {e}",
-                    error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            exposed_data = data_format.deserialise(exposed_data_raw)
-            await self.output(exposed_data)
-            self.host.quit()
-
-
-class Application(base.CommandBase):
-    subcommands = (List, Start, Stop, Exposed)
-
-    def __init__(self, host):
-        super(Application, self).__init__(
-            host, "application", use_profile=False, help=_("manage applications"),
-            aliases=['app'],
-        )
--- a/libervia/frontends/jp/cmd_avatar.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,135 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import os
-import os.path
-import asyncio
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools import config
-from libervia.backend.tools.common import data_format
-
-
-__commands__ = ["Avatar"]
-DISPLAY_CMD = ["xdg-open", "xv", "display", "gwenview", "showtell"]
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        super(Get, self).__init__(
-            host, "get", use_verbose=True, help=_("retrieve avatar of an entity")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--no-cache", action="store_true", help=_("do no use cached values")
-        )
-        self.parser.add_argument(
-            "-s", "--show", action="store_true", help=_("show avatar")
-        )
-        self.parser.add_argument("jid", nargs='?', default='', help=_("entity"))
-
-    async def show_image(self, path):
-        sat_conf = config.parse_main_conf()
-        cmd = config.config_get(sat_conf, C.CONFIG_SECTION, "image_cmd")
-        cmds = [cmd] + DISPLAY_CMD if cmd else DISPLAY_CMD
-        for cmd in cmds:
-            try:
-                process = await asyncio.create_subprocess_exec(cmd, path)
-                ret = await process.wait()
-            except OSError:
-                continue
-
-            if ret in (0, 2):
-                # we can get exit code 2 with display when stopping it with C-c
-                break
-        else:
-            # didn't worked with commands, we try our luck with webbrowser
-            # in some cases, webbrowser can actually open the associated display program.
-            # Note that this may be possibly blocking, depending on the platform and
-            # available browser
-            import webbrowser
-
-            webbrowser.open(path)
-
-    async def start(self):
-        try:
-            avatar_data_raw = await self.host.bridge.avatar_get(
-                self.args.jid,
-                not self.args.no_cache,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve avatar: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        avatar_data = data_format.deserialise(avatar_data_raw, type_check=None)
-
-        if not avatar_data:
-            self.disp(_("No avatar found."), 1)
-            self.host.quit(C.EXIT_NOT_FOUND)
-
-        avatar_path = avatar_data['path']
-
-        self.disp(avatar_path)
-        if self.args.show:
-            await self.show_image(avatar_path)
-
-        self.host.quit()
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        super(Set, self).__init__(
-            host, "set", use_verbose=True,
-            help=_("set avatar of the profile or an entity")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j", "--jid", default='', help=_("entity whose avatar must be changed"))
-        self.parser.add_argument(
-            "image_path", type=str, help=_("path to the image to upload")
-        )
-
-    async def start(self):
-        path = self.args.image_path
-        if not os.path.exists(path):
-            self.disp(_("file {path} doesn't exist!").format(path=repr(path)), error=True)
-            self.host.quit(C.EXIT_BAD_ARG)
-        path = os.path.abspath(path)
-        try:
-            await self.host.bridge.avatar_set(path, self.args.jid, self.profile)
-        except Exception as e:
-            self.disp(f"can't set avatar: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("avatar has been set"), 1)
-            self.host.quit()
-
-
-class Avatar(base.CommandBase):
-    subcommands = (Get, Set)
-
-    def __init__(self, host):
-        super(Avatar, self).__init__(
-            host, "avatar", use_profile=False, help=_("avatar uploading/retrieving")
-        )
--- a/libervia/frontends/jp/cmd_blocking.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,137 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import json
-import os
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp import common
-from libervia.frontends.jp.constants import Const as C
-from . import base
-
-__commands__ = ["Blocking"]
-
-
-class List(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "list",
-            use_output=C.OUTPUT_LIST,
-            help=_("list blocked entities"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            blocked_jids = await self.host.bridge.blocking_list(
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get blocked entities: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(blocked_jids)
-            self.host.quit(C.EXIT_OK)
-
-
-class Block(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "block",
-            help=_("block one or more entities"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "entities",
-            nargs="+",
-            metavar="JID",
-            help=_("JIDs of entities to block"),
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.blocking_block(
-                self.args.entities,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't block entities: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class Unblock(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "unblock",
-            help=_("unblock one or more entities"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "entities",
-            nargs="+",
-            metavar="JID",
-            help=_("JIDs of entities to unblock"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_('when "all" is used, unblock all entities without confirmation'),
-        )
-
-    async def start(self):
-        if self.args.entities == ["all"]:
-            if not self.args.force:
-                await self.host.confirm_or_quit(
-                    _("All entities will be unblocked, are you sure"),
-                    _("unblock cancelled")
-                )
-            self.args.entities.clear()
-        elif self.args.force:
-            self.parser.error(_('--force is only allowed when "all" is used as target'))
-
-        try:
-            await self.host.bridge.blocking_unblock(
-                self.args.entities,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't unblock entities: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class Blocking(base.CommandBase):
-    subcommands = (List, Block, Unblock)
-
-    def __init__(self, host):
-        super().__init__(
-            host, "blocking", use_profile=False, help=_("entities blocking")
-        )
--- a/libervia/frontends/jp/cmd_blog.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1219 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import asyncio
-from asyncio.subprocess import DEVNULL
-from configparser import NoOptionError, NoSectionError
-import json
-import os
-import os.path
-from pathlib import Path
-import re
-import subprocess
-import sys
-import tempfile
-from urllib.parse import urlparse
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools import config
-from libervia.backend.tools.common import uri
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.frontends.jp import common
-from libervia.frontends.jp.constants import Const as C
-
-from . import base, cmd_pubsub
-
-__commands__ = ["Blog"]
-
-SYNTAX_XHTML = "xhtml"
-# extensions to use with known syntaxes
-SYNTAX_EXT = {
-    # FIXME: default syntax doesn't sounds needed, there should always be a syntax set
-    #        by the plugin.
-    "": "txt",  # used when the syntax is not found
-    SYNTAX_XHTML: "xhtml",
-    "markdown": "md",
-}
-
-
-CONF_SYNTAX_EXT = "syntax_ext_dict"
-BLOG_TMP_DIR = "blog"
-# key to remove from metadata tmp file if they exist
-KEY_TO_REMOVE_METADATA = (
-    "id",
-    "content",
-    "content_xhtml",
-    "comments_node",
-    "comments_service",
-    "updated",
-)
-
-URL_REDIRECT_PREFIX = "url_redirect_"
-AIONOTIFY_INSTALL = '"pip install aionotify"'
-MB_KEYS = (
-    "id",
-    "url",
-    "atom_id",
-    "updated",
-    "published",
-    "language",
-    "comments",  # this key is used for all comments* keys
-    "tags",  # this key is used for all tag* keys
-    "author",
-    "author_jid",
-    "author_email",
-    "author_jid_verified",
-    "content",
-    "content_xhtml",
-    "title",
-    "title_xhtml",
-    "extra"
-)
-OUTPUT_OPT_NO_HEADER = "no-header"
-RE_ATTACHMENT_METADATA = re.compile(r"^(?P<key>[a-z_]+)=(?P<value>.*)")
-ALLOWER_ATTACH_MD_KEY = ("desc", "media_type", "external")
-
-
-async def guess_syntax_from_path(host, sat_conf, path):
-    """Return syntax guessed according to filename extension
-
-    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
-    @param path(str): path to the content file
-    @return(unicode): syntax to use
-    """
-    # we first try to guess syntax with extension
-    ext = os.path.splitext(path)[1][1:]  # we get extension without the '.'
-    if ext:
-        for k, v in SYNTAX_EXT.items():
-            if k and ext == v:
-                return k
-
-                # if not found, we use current syntax
-    return await host.bridge.param_get_a("Syntax", "Composition", "value", host.profile)
-
-
-class BlogPublishCommon:
-    """handle common option for publising commands (Set and Edit)"""
-
-    async def get_current_syntax(self):
-        """Retrieve current_syntax
-
-        Use default syntax if --syntax has not been used, else check given syntax.
-        Will set self.default_syntax_used to True if default syntax has been used
-        """
-        if self.args.syntax is None:
-            self.default_syntax_used = True
-            return await self.host.bridge.param_get_a(
-                "Syntax", "Composition", "value", self.profile
-            )
-        else:
-            self.default_syntax_used = False
-            try:
-                syntax = await self.host.bridge.syntax_get(self.args.syntax)
-                self.current_syntax = self.args.syntax = syntax
-            except Exception as e:
-                if e.classname == "NotFound":
-                    self.parser.error(
-                        _("unknown syntax requested ({syntax})").format(
-                            syntax=self.args.syntax
-                        )
-                    )
-                else:
-                    raise e
-        return self.args.syntax
-
-    def add_parser_options(self):
-        self.parser.add_argument("-T", "--title", help=_("title of the item"))
-        self.parser.add_argument(
-            "-t",
-            "--tag",
-            action="append",
-            help=_("tag (category) of your item"),
-        )
-        self.parser.add_argument(
-            "-l",
-            "--language",
-            help=_("language of the item (ISO 639 code)"),
-        )
-
-        self.parser.add_argument(
-            "-a",
-            "--attachment",
-            dest="attachments",
-            nargs="+",
-            help=_(
-                "attachment in the form URL [metadata_name=value]"
-            )
-        )
-
-        comments_group = self.parser.add_mutually_exclusive_group()
-        comments_group.add_argument(
-            "-C",
-            "--comments",
-            action="store_const",
-            const=True,
-            dest="comments",
-            help=_(
-                "enable comments (default: comments not enabled except if they "
-                "already exist)"
-            ),
-        )
-        comments_group.add_argument(
-            "--no-comments",
-            action="store_const",
-            const=False,
-            dest="comments",
-            help=_("disable comments (will remove comments node if it exist)"),
-        )
-
-        self.parser.add_argument(
-            "-S",
-            "--syntax",
-            help=_("syntax to use (default: get profile's default syntax)"),
-        )
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the blog post")
-        )
-        self.parser.add_argument(
-            "--encrypt-for",
-            metavar="JID",
-            action="append",
-            help=_("encrypt a single item for")
-        )
-        self.parser.add_argument(
-            "-X",
-            "--sign",
-            action="store_true",
-            help=_("cryptographically sign the blog post")
-        )
-
-    async def set_mb_data_content(self, content, mb_data):
-        if self.default_syntax_used:
-            # default syntax has been used
-            mb_data["content_rich"] = content
-        elif self.current_syntax == SYNTAX_XHTML:
-            mb_data["content_xhtml"] = content
-        else:
-            mb_data["content_xhtml"] = await self.host.bridge.syntax_convert(
-                content, self.current_syntax, SYNTAX_XHTML, False, self.profile
-            )
-
-    def handle_attachments(self, mb_data: dict) -> None:
-        """Check, validate and add attachments to mb_data"""
-        if self.args.attachments:
-            attachments = []
-            attachment = {}
-            for arg in self.args.attachments:
-                m = RE_ATTACHMENT_METADATA.match(arg)
-                if m is None:
-                    # we should have an URL
-                    url_parsed = urlparse(arg)
-                    if url_parsed.scheme not in ("http", "https"):
-                        self.parser.error(
-                            "invalid URL in --attachment (only http(s) scheme is "
-                            f" accepted): {arg}"
-                        )
-                    if attachment:
-                        # if we hae a new URL, we have a new attachment
-                        attachments.append(attachment)
-                        attachment = {}
-                    attachment["url"] = arg
-                else:
-                    # we should have a metadata
-                    if "url" not in attachment:
-                        self.parser.error(
-                            "you must to specify an URL before any metadata in "
-                            "--attachment"
-                        )
-                    key = m.group("key")
-                    if key not in ALLOWER_ATTACH_MD_KEY:
-                        self.parser.error(
-                            f"invalid metadata key in --attachment: {key!r}"
-                        )
-                    value = m.group("value").strip()
-                    if key == "external":
-                        if not value:
-                            value=True
-                        else:
-                            value = C.bool(value)
-                    attachment[key] = value
-            if attachment:
-                attachments.append(attachment)
-            if attachments:
-                mb_data.setdefault("extra", {})["attachments"] = attachments
-
-    def set_mb_data_from_args(self, mb_data):
-        """set microblog metadata according to command line options
-
-        if metadata already exist, it will be overwritten
-        """
-        if self.args.comments is not None:
-            mb_data["allow_comments"] = self.args.comments
-        if self.args.tag:
-            mb_data["tags"] = self.args.tag
-        if self.args.title is not None:
-            mb_data["title"] = self.args.title
-        if self.args.language is not None:
-            mb_data["language"] = self.args.language
-        if self.args.encrypt:
-            mb_data["encrypted"] = True
-        if self.args.sign:
-            mb_data["signed"] = True
-        if self.args.encrypt_for:
-            mb_data["encrypted_for"] = {"targets": self.args.encrypt_for}
-        self.handle_attachments(mb_data)
-
-
-class Set(base.CommandBase, BlogPublishCommon):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("publish a new blog item or update an existing one"),
-        )
-        BlogPublishCommon.__init__(self)
-
-    def add_parser_options(self):
-        BlogPublishCommon.add_parser_options(self)
-
-    async def start(self):
-        self.current_syntax = await self.get_current_syntax()
-        self.pubsub_item = self.args.item
-        mb_data = {}
-        self.set_mb_data_from_args(mb_data)
-        if self.pubsub_item:
-            mb_data["id"] = self.pubsub_item
-        content = sys.stdin.read()
-        await self.set_mb_data_content(content, mb_data)
-
-        try:
-            item_id = await self.host.bridge.mb_send(
-                self.args.service,
-                self.args.node,
-                data_format.serialise(mb_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(f"Item published with ID {item_id}")
-            self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    TEMPLATE = "blog/articles.html"
-
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output, "fancy": self.fancy_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
-            use_output=C.OUTPUT_COMPLEX,
-            extra_outputs=extra_outputs,
-            help=_("get blog item(s)"),
-        )
-
-    def add_parser_options(self):
-        #  TODO: a key(s) argument to select keys to display
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            action="append",
-            dest="keys",
-            help=_("microblog data key(s) to display (default: depend of verbosity)"),
-        )
-        # TODO: add MAM filters
-
-    def template_data_mapping(self, data):
-        items, blog_items = data
-        blog_items["items"] = items
-        return {"blog_items": blog_items}
-
-    def format_comments(self, item, keys):
-        lines = []
-        for data in item.get("comments", []):
-            lines.append(data["uri"])
-            for k in ("node", "service"):
-                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
-                    header = ""
-                else:
-                    header = f"{C.A_HEADER}comments_{k}: {A.RESET}"
-                lines.append(header + data[k])
-        return "\n".join(lines)
-
-    def format_tags(self, item, keys):
-        tags = item.pop("tags", [])
-        return ", ".join(tags)
-
-    def format_updated(self, item, keys):
-        return common.format_time(item["updated"])
-
-    def format_published(self, item, keys):
-        return common.format_time(item["published"])
-
-    def format_url(self, item, keys):
-        return uri.build_xmpp_uri(
-            "pubsub",
-            subtype="microblog",
-            path=self.metadata["service"],
-            node=self.metadata["node"],
-            item=item["id"],
-        )
-
-    def get_keys(self):
-        """return keys to display according to verbosity or explicit key request"""
-        verbosity = self.args.verbose
-        if self.args.keys:
-            if not set(MB_KEYS).issuperset(self.args.keys):
-                self.disp(
-                    "following keys are invalid: {invalid}.\n"
-                    "Valid keys are: {valid}.".format(
-                        invalid=", ".join(set(self.args.keys).difference(MB_KEYS)),
-                        valid=", ".join(sorted(MB_KEYS)),
-                    ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-            return self.args.keys
-        else:
-            if verbosity == 0:
-                return ("title", "content")
-            elif verbosity == 1:
-                return (
-                    "title",
-                    "tags",
-                    "author",
-                    "author_jid",
-                    "author_email",
-                    "author_jid_verified",
-                    "published",
-                    "updated",
-                    "content",
-                )
-            else:
-                return MB_KEYS
-
-    def default_output(self, data):
-        """simple key/value output"""
-        items, self.metadata = data
-        keys = self.get_keys()
-
-        #  k_cb use format_[key] methods for complex formattings
-        k_cb = {}
-        for k in keys:
-            try:
-                callback = getattr(self, "format_" + k)
-            except AttributeError:
-                pass
-            else:
-                k_cb[k] = callback
-        for idx, item in enumerate(items):
-            for k in keys:
-                if k not in item and k not in k_cb:
-                    continue
-                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
-                    header = ""
-                else:
-                    header = "{k_fmt}{key}:{k_fmt_e} {sep}".format(
-                        k_fmt=C.A_HEADER,
-                        key=k,
-                        k_fmt_e=A.RESET,
-                        sep="\n" if "content" in k else "",
-                    )
-                value = k_cb[k](item, keys) if k in k_cb else item[k]
-                if isinstance(value, bool):
-                    value = str(value).lower()
-                elif isinstance(value, dict):
-                    value = repr(value)
-                self.disp(header + (value or ""))
-                # we want a separation line after each item but the last one
-            if idx < len(items) - 1:
-                print("")
-
-    def fancy_output(self, data):
-        """display blog is a nice to read way
-
-        this output doesn't use keys filter
-        """
-        # thanks to http://stackoverflow.com/a/943921
-        rows, columns = list(map(int, os.popen("stty size", "r").read().split()))
-        items, metadata = data
-        verbosity = self.args.verbose
-        sep = A.color(A.FG_BLUE, columns * "▬")
-        if items:
-            print(("\n" + sep + "\n"))
-
-        for idx, item in enumerate(items):
-            title = item.get("title")
-            if verbosity > 0:
-                author = item["author"]
-                published, updated = item["published"], item.get("updated")
-            else:
-                author = published = updated = None
-            if verbosity > 1:
-                tags = item.pop("tags", [])
-            else:
-                tags = None
-            content = item.get("content")
-
-            if title:
-                print((A.color(A.BOLD, A.FG_CYAN, item["title"])))
-            meta = []
-            if author:
-                meta.append(A.color(A.FG_YELLOW, author))
-            if published:
-                meta.append(A.color(A.FG_YELLOW, "on ", common.format_time(published)))
-            if updated != published:
-                meta.append(
-                    A.color(A.FG_YELLOW, "(updated on ", common.format_time(updated), ")")
-                )
-            print((" ".join(meta)))
-            if tags:
-                print((A.color(A.FG_MAGENTA, ", ".join(tags))))
-            if (title or tags) and content:
-                print("")
-            if content:
-                self.disp(content)
-
-            print(("\n" + sep + "\n"))
-
-    async def start(self):
-        try:
-            mb_data = data_format.deserialise(
-                await self.host.bridge.mb_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't get blog items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            items = mb_data.pop("items")
-            await self.output((items, mb_data))
-            self.host.quit(C.EXIT_OK)
-
-
-class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            use_draft=True,
-            use_verbose=True,
-            help=_("edit an existing or new blog post"),
-        )
-        BlogPublishCommon.__init__(self)
-        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
-
-    def add_parser_options(self):
-        BlogPublishCommon.add_parser_options(self)
-        self.parser.add_argument(
-            "-P",
-            "--preview",
-            action="store_true",
-            help=_("launch a blog preview in parallel"),
-        )
-        self.parser.add_argument(
-            "--no-publish",
-            action="store_true",
-            help=_('add "publish: False" to metadata'),
-        )
-
-    def build_metadata_file(self, content_file_path, mb_data=None):
-        """Build a metadata file using json
-
-        The file is named after content_file_path, with extension replaced by
-        _metadata.json
-        @param content_file_path(str): path to the temporary file which will contain the
-            body
-        @param mb_data(dict, None): microblog metadata (for existing items)
-        @return (tuple[dict, Path]): merged metadata put originaly in metadata file
-            and path to temporary metadata file
-        """
-        # we first construct metadata from edited item ones and CLI argumments
-        # or re-use the existing one if it exists
-        meta_file_path = content_file_path.with_name(
-            content_file_path.stem + common.METADATA_SUFF
-        )
-        if meta_file_path.exists():
-            self.disp("Metadata file already exists, we re-use it")
-            try:
-                with meta_file_path.open("rb") as f:
-                    mb_data = json.load(f)
-            except (OSError, IOError, ValueError) as e:
-                self.disp(
-                    f"Can't read existing metadata file at {meta_file_path}, "
-                    f"aborting: {e}",
-                    error=True,
-                )
-                self.host.quit(1)
-        else:
-            mb_data = {} if mb_data is None else mb_data.copy()
-
-            # in all cases, we want to remove unwanted keys
-        for key in KEY_TO_REMOVE_METADATA:
-            try:
-                del mb_data[key]
-            except KeyError:
-                pass
-                # and override metadata with command-line arguments
-        self.set_mb_data_from_args(mb_data)
-
-        if self.args.no_publish:
-            mb_data["publish"] = False
-
-            # then we create the file and write metadata there, as JSON dict
-            # XXX: if we port jp one day on Windows, O_BINARY may need to be added here
-        with os.fdopen(
-            os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b"
-        ) as f:
-            # we need to use an intermediate unicode buffer to write to the file
-            # unicode without escaping characters
-            unicode_dump = json.dumps(
-                mb_data,
-                ensure_ascii=False,
-                indent=4,
-                separators=(",", ": "),
-                sort_keys=True,
-            )
-            f.write(unicode_dump.encode("utf-8"))
-
-        return mb_data, meta_file_path
-
-    async def edit(self, content_file_path, content_file_obj, mb_data=None):
-        """Edit the file contening the content using editor, and publish it"""
-        # we first create metadata file
-        meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data)
-
-        coroutines = []
-
-        # do we need a preview ?
-        if self.args.preview:
-            self.disp("Preview requested, launching it", 1)
-            # we redirect outputs to /dev/null to avoid console pollution in editor
-            # if user wants to see messages, (s)he can call "blog preview" directly
-            coroutines.append(
-                asyncio.create_subprocess_exec(
-                    sys.argv[0],
-                    "blog",
-                    "preview",
-                    "--inotify",
-                    "true",
-                    "-p",
-                    self.profile,
-                    str(content_file_path),
-                    stdout=DEVNULL,
-                    stderr=DEVNULL,
-                )
-            )
-
-            # we launch editor
-        coroutines.append(
-            self.run_editor(
-                "blog_editor_args",
-                content_file_path,
-                content_file_obj,
-                meta_file_path=meta_file_path,
-                meta_ori=meta_ori,
-            )
-        )
-
-        await asyncio.gather(*coroutines)
-
-    async def publish(self, content, mb_data):
-        await self.set_mb_data_content(content, mb_data)
-
-        if self.pubsub_item:
-            mb_data["id"] = self.pubsub_item
-
-        mb_data = data_format.serialise(mb_data)
-
-        await self.host.bridge.mb_send(
-            self.pubsub_service, self.pubsub_node, mb_data, self.profile
-        )
-        self.disp("Blog item published")
-
-    def get_tmp_suff(self):
-        # we get current syntax to determine file extension
-        return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""])
-
-    async def get_item_data(self, service, node, item):
-        items = [item] if item else []
-
-        mb_data = data_format.deserialise(
-            await self.host.bridge.mb_get(
-                service, node, 1, items, data_format.serialise({}), self.profile
-            )
-        )
-        item = mb_data["items"][0]
-
-        try:
-            content = item["content_xhtml"]
-        except KeyError:
-            content = item["content"]
-            if content:
-                content = await self.host.bridge.syntax_convert(
-                    content, "text", SYNTAX_XHTML, False, self.profile
-                )
-
-        if content and self.current_syntax != SYNTAX_XHTML:
-            content = await self.host.bridge.syntax_convert(
-                content, SYNTAX_XHTML, self.current_syntax, False, self.profile
-            )
-
-        if content and self.current_syntax == SYNTAX_XHTML:
-            content = content.strip()
-            if not content.startswith("<div>"):
-                content = "<div>" + content + "</div>"
-            try:
-                from lxml import etree
-            except ImportError:
-                self.disp(_("You need lxml to edit pretty XHTML"))
-            else:
-                parser = etree.XMLParser(remove_blank_text=True)
-                root = etree.fromstring(content, parser)
-                content = etree.tostring(root, encoding=str, pretty_print=True)
-
-        return content, item, item["id"]
-
-    async def start(self):
-        # if there are user defined extension, we use them
-        SYNTAX_EXT.update(
-            config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
-        )
-        self.current_syntax = await self.get_current_syntax()
-
-        (
-            self.pubsub_service,
-            self.pubsub_node,
-            self.pubsub_item,
-            content_file_path,
-            content_file_obj,
-            mb_data,
-        ) = await self.get_item_path()
-
-        await self.edit(content_file_path, content_file_obj, mb_data=mb_data)
-        self.host.quit()
-
-
-class Rename(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "rename",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("rename an blog item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("new_id", help=_("new item id to use"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.mb_rename(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.new_id,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't rename item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("Item renamed")
-            self.host.quit(C.EXIT_OK)
-
-
-class Repeat(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "repeat",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("repeat (re-publish) a blog item"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            repeat_id = await self.host.bridge.mb_repeat(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't repeat item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if repeat_id:
-                self.disp(f"Item repeated at ID {str(repeat_id)!r}")
-            else:
-                self.disp("Item repeated")
-            self.host.quit(C.EXIT_OK)
-
-
-class Preview(base.CommandBase, common.BaseEdit):
-    # TODO: need to be rewritten with template output
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "preview", use_verbose=True, help=_("preview a blog content")
-        )
-        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--inotify",
-            type=str,
-            choices=("auto", "true", "false"),
-            default="auto",
-            help=_("use inotify to handle preview"),
-        )
-        self.parser.add_argument(
-            "file",
-            nargs="?",
-            default="current",
-            help=_("path to the content file"),
-        )
-
-    async def show_preview(self):
-        # we implement show_preview here so we don't have to import webbrowser and urllib
-        # when preview is not used
-        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
-        self.webbrowser.open_new_tab(url)
-
-    async def _launch_preview_ext(self, cmd_line, opt_name):
-        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
-        args = common.parse_args(
-            self.host, cmd_line, url=url, preview_file=self.preview_file_path
-        )
-        if not args:
-            self.disp(
-                'Couln\'t find command in "{name}", abording'.format(name=opt_name),
-                error=True,
-            )
-            self.host.quit(1)
-        subprocess.Popen(args)
-
-    async def open_preview_ext(self):
-        await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd")
-
-    async def update_preview_ext(self):
-        await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd")
-
-    async def update_content(self):
-        with self.content_file_path.open("rb") as f:
-            content = f.read().decode("utf-8-sig")
-            if content and self.syntax != SYNTAX_XHTML:
-                # we use safe=True because we want to have a preview as close as possible
-                # to what the people will see
-                content = await self.host.bridge.syntax_convert(
-                    content, self.syntax, SYNTAX_XHTML, True, self.profile
-                )
-
-        xhtml = (
-            f'<html xmlns="http://www.w3.org/1999/xhtml">'
-            f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'
-            f"</head>"
-            f"<body>{content}</body>"
-            f"</html>"
-        )
-
-        with open(self.preview_file_path, "wb") as f:
-            f.write(xhtml.encode("utf-8"))
-
-    async def start(self):
-        import webbrowser
-        import urllib.request, urllib.parse, urllib.error
-
-        self.webbrowser, self.urllib = webbrowser, urllib
-
-        if self.args.inotify != "false":
-            try:
-                import aionotify
-
-            except ImportError:
-                if self.args.inotify == "auto":
-                    aionotify = None
-                    self.disp(
-                        f"aionotify module not found, deactivating feature. You can "
-                        f"install it with {AIONOTIFY_INSTALL}"
-                    )
-                else:
-                    self.disp(
-                        f"aioinotify not found, can't activate the feature! Please "
-                        f"install it with {AIONOTIFY_INSTALL}",
-                        error=True,
-                    )
-                    self.host.quit(1)
-        else:
-            aionotify = None
-
-        sat_conf = self.sat_conf
-        SYNTAX_EXT.update(
-            config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
-        )
-
-        try:
-            self.open_cb_cmd = config.config_get(
-                sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception
-            )
-        except (NoOptionError, NoSectionError):
-            self.open_cb_cmd = None
-            open_cb = self.show_preview
-        else:
-            open_cb = self.open_preview_ext
-
-        self.update_cb_cmd = config.config_get(
-            sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd
-        )
-        if self.update_cb_cmd is None:
-            update_cb = self.show_preview
-        else:
-            update_cb = self.update_preview_ext
-
-            # which file do we need to edit?
-        if self.args.file == "current":
-            self.content_file_path = self.get_current_file(self.profile)
-        else:
-            try:
-                self.content_file_path = Path(self.args.file).resolve(strict=True)
-            except FileNotFoundError:
-                self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file))
-                self.host.quit(C.EXIT_NOT_FOUND)
-
-        self.syntax = await guess_syntax_from_path(
-            self.host, sat_conf, self.content_file_path
-        )
-
-        # at this point the syntax is converted, we can display the preview
-        preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False)
-        self.preview_file_path = preview_file.name
-        preview_file.close()
-        await self.update_content()
-
-        if aionotify is None:
-            # XXX: we don't delete file automatically because browser needs it
-            #      (and webbrowser.open can return before it is read)
-            self.disp(
-                f"temporary file created at {self.preview_file_path}\nthis file will NOT "
-                f"BE DELETED AUTOMATICALLY, please delete it yourself when you have "
-                f"finished"
-            )
-            await open_cb()
-        else:
-            await open_cb()
-            watcher = aionotify.Watcher()
-            watcher_kwargs = {
-                # Watcher don't accept Path so we convert to string
-                "path": str(self.content_file_path),
-                "alias": "content_file",
-                "flags": aionotify.Flags.CLOSE_WRITE
-                | aionotify.Flags.DELETE_SELF
-                | aionotify.Flags.MOVE_SELF,
-            }
-            watcher.watch(**watcher_kwargs)
-
-            loop = asyncio.get_event_loop()
-            await watcher.setup(loop)
-
-            try:
-                while True:
-                    event = await watcher.get_event()
-                    self.disp("Content updated", 1)
-                    if event.flags & (
-                        aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF
-                    ):
-                        self.disp(
-                            "DELETE/MOVE event catched, changing the watch",
-                            2,
-                        )
-                        try:
-                            watcher.unwatch("content_file")
-                        except IOError as e:
-                            self.disp(
-                                f"Can't remove the watch: {e}",
-                                2,
-                            )
-                        watcher = aionotify.Watcher()
-                        watcher.watch(**watcher_kwargs)
-                        try:
-                            await watcher.setup(loop)
-                        except OSError:
-                            # if the new file is not here yet we can have an error
-                            # as a workaround, we do a little rest and try again
-                            await asyncio.sleep(1)
-                            await watcher.setup(loop)
-                    await self.update_content()
-                    await update_cb()
-            except FileNotFoundError:
-                self.disp("The file seems to have been deleted.", error=True)
-                self.host.quit(C.EXIT_NOT_FOUND)
-            finally:
-                os.unlink(self.preview_file_path)
-                try:
-                    watcher.unwatch("content_file")
-                except IOError as e:
-                    self.disp(
-                        f"Can't remove the watch: {e}",
-                        2,
-                    )
-
-
-class Import(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "import",
-            use_pubsub=True,
-            use_progress=True,
-            help=_("import an external blog"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "importer",
-            nargs="?",
-            help=_("importer name, nothing to display importers list"),
-        )
-        self.parser.add_argument("--host", help=_("original blog host"))
-        self.parser.add_argument(
-            "--no-images-upload",
-            action="store_true",
-            help=_("do *NOT* upload images (default: do upload images)"),
-        )
-        self.parser.add_argument(
-            "--upload-ignore-host",
-            help=_("do not upload images from this host (default: upload all images)"),
-        )
-        self.parser.add_argument(
-            "--ignore-tls-errors",
-            action="store_true",
-            help=_("ignore invalide TLS certificate for uploads"),
-        )
-        self.parser.add_argument(
-            "-o",
-            "--option",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar=("NAME", "VALUE"),
-            help=_("importer specific options (see importer description)"),
-        )
-        self.parser.add_argument(
-            "location",
-            nargs="?",
-            help=_(
-                "importer data location (see importer description), nothing to show "
-                "importer description"
-            ),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("Blog upload started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("Blog uploaded successfully"), 2)
-        redirections = {
-            k[len(URL_REDIRECT_PREFIX) :]: v
-            for k, v in metadata.items()
-            if k.startswith(URL_REDIRECT_PREFIX)
-        }
-        if redirections:
-            conf = "\n".join(
-                [
-                    "url_redirections_dict = {}".format(
-                        # we need to add ' ' before each new line
-                        # and to double each '%' for ConfigParser
-                        "\n ".join(
-                            json.dumps(redirections, indent=1, separators=(",", ": "))
-                            .replace("%", "%%")
-                            .split("\n")
-                        )
-                    ),
-                ]
-            )
-            self.disp(
-                _(
-                    "\nTo redirect old URLs to new ones, put the following lines in your"
-                    " sat.conf file, in [libervia] section:\n\n{conf}"
-                ).format(conf=conf)
-            )
-
-    async def on_progress_error(self, error_msg):
-        self.disp(
-            _("Error while uploading blog: {error_msg}").format(error_msg=error_msg),
-            error=True,
-        )
-
-    async def start(self):
-        if self.args.location is None:
-            for name in ("option", "service", "no_images_upload"):
-                if getattr(self.args, name):
-                    self.parser.error(
-                        _(
-                            "{name} argument can't be used without location argument"
-                        ).format(name=name)
-                    )
-            if self.args.importer is None:
-                self.disp(
-                    "\n".join(
-                        [
-                            f"{name}: {desc}"
-                            for name, desc in await self.host.bridge.blogImportList()
-                        ]
-                    )
-                )
-            else:
-                try:
-                    short_desc, long_desc = await self.host.bridge.blogImportDesc(
-                        self.args.importer
-                    )
-                except Exception as e:
-                    msg = [l for l in str(e).split("\n") if l][
-                        -1
-                    ]  # we only keep the last line
-                    self.disp(msg)
-                    self.host.quit(1)
-                else:
-                    self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}")
-            self.host.quit()
-        else:
-            # we have a location, an import is requested
-            options = {key: value for key, value in self.args.option}
-            if self.args.host:
-                options["host"] = self.args.host
-            if self.args.ignore_tls_errors:
-                options["ignore_tls_errors"] = C.BOOL_TRUE
-            if self.args.no_images_upload:
-                options["upload_images"] = C.BOOL_FALSE
-                if self.args.upload_ignore_host:
-                    self.parser.error(
-                        "upload-ignore-host option can't be used when no-images-upload "
-                        "is set"
-                    )
-            elif self.args.upload_ignore_host:
-                options["upload_ignore_host"] = self.args.upload_ignore_host
-
-            try:
-                progress_id = await self.host.bridge.blogImport(
-                    self.args.importer,
-                    self.args.location,
-                    options,
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("Error while trying to import a blog: {e}").format(e=e),
-                    error=True,
-                )
-                self.host.quit(1)
-            else:
-                await self.set_progress_id(progress_id)
-
-
-class AttachmentGet(cmd_pubsub.AttachmentGet):
-
-    def __init__(self, host):
-        super().__init__(host)
-        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
-
-
-    async def start(self):
-        if not self.args.node:
-            namespaces = await self.host.bridge.namespaces_get()
-            try:
-                ns_microblog = namespaces["microblog"]
-            except KeyError:
-                self.disp("XEP-0277 plugin is not loaded", error=True)
-                self.host.quit(C.EXIT_MISSING_FEATURE)
-            else:
-                self.args.node = ns_microblog
-        return await super().start()
-
-
-class AttachmentSet(cmd_pubsub.AttachmentSet):
-
-    def __init__(self, host):
-        super().__init__(host)
-        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
-
-    async def start(self):
-        if not self.args.node:
-            namespaces = await self.host.bridge.namespaces_get()
-            try:
-                ns_microblog = namespaces["microblog"]
-            except KeyError:
-                self.disp("XEP-0277 plugin is not loaded", error=True)
-                self.host.quit(C.EXIT_MISSING_FEATURE)
-            else:
-                self.args.node = ns_microblog
-        return await super().start()
-
-
-class Attachments(base.CommandBase):
-    subcommands = (AttachmentGet, AttachmentSet)
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "attachments",
-            use_profile=False,
-            help=_("set or retrieve blog attachments"),
-        )
-
-
-class Blog(base.CommandBase):
-    subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments)
-
-    def __init__(self, host):
-        super(Blog, self).__init__(
-            host, "blog", use_profile=False, help=_("blog/microblog management")
-        )
--- a/libervia/frontends/jp/cmd_bookmarks.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,175 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-
-__commands__ = ["Bookmarks"]
-
-STORAGE_LOCATIONS = ("local", "private", "pubsub")
-TYPES = ("muc", "url")
-
-
-class BookmarksCommon(base.CommandBase):
-    """Class used to group common options of bookmarks subcommands"""
-
-    def add_parser_options(self, location_default="all"):
-        self.parser.add_argument(
-            "-l",
-            "--location",
-            type=str,
-            choices=(location_default,) + STORAGE_LOCATIONS,
-            default=location_default,
-            help=_("storage location (default: %(default)s)"),
-        )
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            type=str,
-            choices=TYPES,
-            default=TYPES[0],
-            help=_("bookmarks type (default: %(default)s)"),
-        )
-
-
-class BookmarksList(BookmarksCommon):
-    def __init__(self, host):
-        super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks"))
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.bookmarks_list(
-                self.args.type, self.args.location, self.host.profile
-            )
-        except Exception as e:
-            self.disp(f"can't get bookmarks list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        mess = []
-        for location in STORAGE_LOCATIONS:
-            if not data[location]:
-                continue
-            loc_mess = []
-            loc_mess.append(f"{location}:")
-            book_mess = []
-            for book_link, book_data in list(data[location].items()):
-                name = book_data.get("name")
-                autojoin = book_data.get("autojoin", "false") == "true"
-                nick = book_data.get("nick")
-                book_mess.append(
-                    "\t%s[%s%s]%s"
-                    % (
-                        (name + " ") if name else "",
-                        book_link,
-                        " (%s)" % nick if nick else "",
-                        " (*)" if autojoin else "",
-                    )
-                )
-            loc_mess.append("\n".join(book_mess))
-            mess.append("\n".join(loc_mess))
-
-        print("\n\n".join(mess))
-        self.host.quit()
-
-
-class BookmarksRemove(BookmarksCommon):
-    def __init__(self, host):
-        super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark"))
-
-    def add_parser_options(self):
-        super(BookmarksRemove, self).add_parser_options()
-        self.parser.add_argument(
-            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("delete bookmark without confirmation"),
-        )
-
-    async def start(self):
-        if not self.args.force:
-            await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?"))
-
-        try:
-            await self.host.bridge.bookmarks_remove(
-                self.args.type, self.args.bookmark, self.args.location, self.host.profile
-            )
-        except Exception as e:
-            self.disp(_("can't delete bookmark: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("bookmark deleted"))
-            self.host.quit()
-
-
-class BookmarksAdd(BookmarksCommon):
-    def __init__(self, host):
-        super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark"))
-
-    def add_parser_options(self):
-        super(BookmarksAdd, self).add_parser_options(location_default="auto")
-        self.parser.add_argument(
-            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
-        )
-        self.parser.add_argument("-n", "--name", help=_("bookmark name"))
-        muc_group = self.parser.add_argument_group(_("MUC specific options"))
-        muc_group.add_argument("-N", "--nick", help=_("nickname"))
-        muc_group.add_argument(
-            "-a",
-            "--autojoin",
-            action="store_true",
-            help=_("join room on profile connection"),
-        )
-
-    async def start(self):
-        if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None):
-            self.parser.error(_("You can't use --autojoin or --nick with --type url"))
-        data = {}
-        if self.args.autojoin:
-            data["autojoin"] = "true"
-        if self.args.nick is not None:
-            data["nick"] = self.args.nick
-        if self.args.name is not None:
-            data["name"] = self.args.name
-        try:
-            await self.host.bridge.bookmarks_add(
-                self.args.type,
-                self.args.bookmark,
-                data,
-                self.args.location,
-                self.host.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't add bookmark: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("bookmark successfully added"))
-            self.host.quit()
-
-
-class Bookmarks(base.CommandBase):
-    subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd)
-
-    def __init__(self, host):
-        super(Bookmarks, self).__init__(
-            host, "bookmarks", use_profile=False, help=_("manage bookmarks")
-        )
--- a/libervia/frontends/jp/cmd_debug.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,228 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-import json
-
-__commands__ = ["Debug"]
-
-
-class BridgeCommon(object):
-    def eval_args(self):
-        if self.args.arg:
-            try:
-                return eval("[{}]".format(",".join(self.args.arg)))
-            except SyntaxError as e:
-                self.disp(
-                    "Can't evaluate arguments: {mess}\n{text}\n{offset}^".format(
-                        mess=e, text=e.text, offset=" " * (e.offset - 1)
-                    ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-        else:
-            return []
-
-
-class Method(base.CommandBase, BridgeCommon):
-    def __init__(self, host):
-        base.CommandBase.__init__(self, host, "method", help=_("call a bridge method"))
-        BridgeCommon.__init__(self)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "method", type=str, help=_("name of the method to execute")
-        )
-        self.parser.add_argument("arg", nargs="*", help=_("argument of the method"))
-
-    async def start(self):
-        method = getattr(self.host.bridge, self.args.method)
-        import inspect
-
-        argspec = inspect.getargspec(method)
-
-        kwargs = {}
-        if "profile_key" in argspec.args:
-            kwargs["profile_key"] = self.profile
-        elif "profile" in argspec.args:
-            kwargs["profile"] = self.profile
-
-        args = self.eval_args()
-
-        try:
-            ret = await method(
-                *args,
-                **kwargs,
-            )
-        except Exception as e:
-            self.disp(
-                _("Error while executing {method}: {e}").format(
-                    method=self.args.method, e=e
-                ),
-                error=True,
-            )
-            self.host.quit(C.EXIT_ERROR)
-        else:
-            if ret is not None:
-                self.disp(str(ret))
-            self.host.quit()
-
-
-class Signal(base.CommandBase, BridgeCommon):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "signal", help=_("send a fake signal from backend")
-        )
-        BridgeCommon.__init__(self)
-
-    def add_parser_options(self):
-        self.parser.add_argument("signal", type=str, help=_("name of the signal to send"))
-        self.parser.add_argument("arg", nargs="*", help=_("argument of the signal"))
-
-    async def start(self):
-        args = self.eval_args()
-        json_args = json.dumps(args)
-        # XXX: we use self.args.profile and not self.profile
-        #      because we want the raw profile_key (so plugin handle C.PROF_KEY_NONE)
-        try:
-            await self.host.bridge.debug_signal_fake(
-                self.args.signal, json_args, self.args.profile
-            )
-        except Exception as e:
-            self.disp(_("Can't send fake signal: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_ERROR)
-        else:
-            self.host.quit()
-
-
-class bridge(base.CommandBase):
-    subcommands = (Method, Signal)
-
-    def __init__(self, host):
-        super(bridge, self).__init__(
-            host, "bridge", use_profile=False, help=_("bridge s(t)imulation")
-        )
-
-
-class Monitor(base.CommandBase):
-    def __init__(self, host):
-        super(Monitor, self).__init__(
-            host,
-            "monitor",
-            use_verbose=True,
-            use_profile=False,
-            use_output=C.OUTPUT_XML,
-            help=_("monitor XML stream"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-d",
-            "--direction",
-            choices=("in", "out", "both"),
-            default="both",
-            help=_("stream direction filter"),
-        )
-
-    async def print_xml(self, direction, xml_data, profile):
-        if self.args.direction == "in" and direction != "IN":
-            return
-        if self.args.direction == "out" and direction != "OUT":
-            return
-        verbosity = self.host.verbosity
-        if not xml_data.strip():
-            if verbosity <= 2:
-                return
-            whiteping = True
-        else:
-            whiteping = False
-
-        if verbosity:
-            profile_disp = f" ({profile})" if verbosity > 1 else ""
-            if direction == "IN":
-                self.disp(
-                    A.color(
-                        A.BOLD, A.FG_YELLOW, "<<<===== IN ====", A.FG_WHITE, profile_disp
-                    )
-                )
-            else:
-                self.disp(
-                    A.color(
-                        A.BOLD, A.FG_CYAN, "==== OUT ====>>>", A.FG_WHITE, profile_disp
-                    )
-                )
-        if whiteping:
-            self.disp("[WHITESPACE PING]")
-        else:
-            try:
-                await self.output(xml_data)
-            except Exception:
-                #  initial stream is not valid XML,
-                # in this case we print directly to data
-                #  FIXME: we should test directly lxml.etree.XMLSyntaxError
-                #        but importing lxml directly here is not clean
-                #        should be wrapped in a custom Exception
-                self.disp(xml_data)
-                self.disp("")
-
-    async def start(self):
-        self.host.bridge.register_signal("xml_log", self.print_xml, "plugin")
-
-
-class Theme(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "theme", help=_("print colours used with your background")
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        print(f"background currently used: {A.BOLD}{self.host.background}{A.RESET}\n")
-        for attr in dir(C):
-            if not attr.startswith("A_"):
-                continue
-            color = getattr(C, attr)
-            if attr == "A_LEVEL_COLORS":
-                # This constant contains multiple colors
-                self.disp("LEVEL COLORS: ", end=" ")
-                for idx, c in enumerate(color):
-                    last = idx == len(color) - 1
-                    end = "\n" if last else " "
-                    self.disp(
-                        c + f"LEVEL_{idx}" + A.RESET + (", " if not last else ""), end=end
-                    )
-            else:
-                text = attr[2:]
-                self.disp(A.color(color, text))
-        self.host.quit()
-
-
-class Debug(base.CommandBase):
-    subcommands = (bridge, Monitor, Theme)
-
-    def __init__(self, host):
-        super(Debug, self).__init__(
-            host, "debug", use_profile=False, help=_("debugging tools")
-        )
--- a/libervia/frontends/jp/cmd_encryption.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,231 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from libervia.frontends.jp import base
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp import xmlui_manager
-
-__commands__ = ["Encryption"]
-
-
-class EncryptionAlgorithms(base.CommandBase):
-
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(EncryptionAlgorithms, self).__init__(
-            host, "algorithms",
-            use_output=C.OUTPUT_LIST_DICT,
-            extra_outputs=extra_outputs,
-            use_profile=False,
-            help=_("show available encryption algorithms"))
-
-    def add_parser_options(self):
-        pass
-
-    def default_output(self, plugins):
-        if not plugins:
-            self.disp(_("No encryption plugin registered!"))
-        else:
-            self.disp(_("Following encryption algorithms are available: {algos}").format(
-                algos=', '.join([p['name'] for p in plugins])))
-
-    async def start(self):
-        try:
-            plugins_ser = await self.host.bridge.encryption_plugins_get()
-            plugins = data_format.deserialise(plugins_ser, type_check=list)
-        except Exception as e:
-            self.disp(f"can't retrieve plugins: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(plugins)
-            self.host.quit()
-
-
-class EncryptionGet(base.CommandBase):
-
-    def __init__(self, host):
-        super(EncryptionGet, self).__init__(
-            host, "get",
-            use_output=C.OUTPUT_DICT,
-            help=_("get encryption session data"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to check")
-        )
-
-    async def start(self):
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        try:
-            serialised = await self.host.bridge.message_encryption_get(jid, self.profile)
-        except Exception as e:
-            self.disp(f"can't get session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        session_data = data_format.deserialise(serialised)
-        if session_data is None:
-            self.disp(
-                "No encryption session found, the messages are sent in plain text.")
-            self.host.quit(C.EXIT_NOT_FOUND)
-        await self.output(session_data)
-        self.host.quit()
-
-
-class EncryptionStart(base.CommandBase):
-
-    def __init__(self, host):
-        super(EncryptionStart, self).__init__(
-            host, "start",
-            help=_("start encrypted session with an entity"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--encrypt-noreplace",
-            action="store_true",
-            help=_("don't replace encryption algorithm if an other one is already used"))
-        algorithm = self.parser.add_mutually_exclusive_group()
-        algorithm.add_argument(
-            "-n", "--name", help=_("algorithm name (DEFAULT: choose automatically)"))
-        algorithm.add_argument(
-            "-N", "--namespace",
-            help=_("algorithm namespace (DEFAULT: choose automatically)"))
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to stop encrypted session with")
-        )
-
-    async def start(self):
-        if self.args.name is not None:
-            try:
-                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
-            except Exception as e:
-                self.disp(f"can't get encryption namespace: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        elif self.args.namespace is not None:
-            namespace = self.args.namespace
-        else:
-            namespace = ""
-
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-
-        try:
-            await self.host.bridge.message_encryption_start(
-                jid, namespace, not self.args.encrypt_noreplace,
-                self.profile)
-        except Exception as e:
-            self.disp(f"can't get encryption namespace: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class EncryptionStop(base.CommandBase):
-
-    def __init__(self, host):
-        super(EncryptionStop, self).__init__(
-            host, "stop",
-            help=_("stop encrypted session with an entity"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to stop encrypted session with")
-        )
-
-    async def start(self):
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        try:
-            await self.host.bridge.message_encryption_stop(jid, self.profile)
-        except Exception as e:
-            self.disp(f"can't end encrypted session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class TrustUI(base.CommandBase):
-
-    def __init__(self, host):
-        super(TrustUI, self).__init__(
-            host, "ui",
-            help=_("get UI to manage trust"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to stop encrypted session with")
-        )
-        algorithm = self.parser.add_mutually_exclusive_group()
-        algorithm.add_argument(
-            "-n", "--name", help=_("algorithm name (DEFAULT: current algorithm)"))
-        algorithm.add_argument(
-            "-N", "--namespace",
-            help=_("algorithm namespace (DEFAULT: current algorithm)"))
-
-    async def start(self):
-        if self.args.name is not None:
-            try:
-                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
-            except Exception as e:
-                self.disp(f"can't get encryption namespace: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        elif self.args.namespace is not None:
-            namespace = self.args.namespace
-        else:
-            namespace = ""
-
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-
-        try:
-            xmlui_raw = await self.host.bridge.encryption_trust_ui_get(
-                jid, namespace, self.profile)
-        except Exception as e:
-            self.disp(f"can't get encryption session trust UI: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        xmlui = xmlui_manager.create(self.host, xmlui_raw)
-        await xmlui.show()
-        if xmlui.type != C.XMLUI_DIALOG:
-            await xmlui.submit_form()
-        self.host.quit()
-
-class EncryptionTrust(base.CommandBase):
-    subcommands = (TrustUI,)
-
-    def __init__(self, host):
-        super(EncryptionTrust, self).__init__(
-            host, "trust", use_profile=False, help=_("trust manangement")
-        )
-
-
-class Encryption(base.CommandBase):
-    subcommands = (EncryptionAlgorithms, EncryptionGet, EncryptionStart, EncryptionStop,
-                   EncryptionTrust)
-
-    def __init__(self, host):
-        super(Encryption, self).__init__(
-            host, "encryption", use_profile=False, help=_("encryption sessions handling")
-        )
--- a/libervia/frontends/jp/cmd_event.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,755 +0,0 @@
-#!/usr/bin/env python3
-
-
-# libervia-cli: Libervia CLI frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import argparse
-import sys
-
-from sqlalchemy import desc
-
-from libervia.backend.core.i18n import _
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common import date_utils
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.frontends.jp import common
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp.constants import Const as C
-
-from . import base
-
-__commands__ = ["Event"]
-
-OUTPUT_OPT_TABLE = "table"
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
-            use_verbose=True,
-            extra_outputs={
-                "default": self.default_output,
-            },
-            help=_("get event(s) data"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            events_data_s = await self.host.bridge.events_get(
-                self.args.service,
-                self.args.node,
-                self.args.items,
-                self.get_pubsub_extra(),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get events data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            events_data = data_format.deserialise(events_data_s, type_check=list)
-            await self.output(events_data)
-            self.host.quit()
-
-    def default_output(self, events):
-        nb_events = len(events)
-        for idx, event in enumerate(events):
-            names = event["name"]
-            name = names.get("") or next(iter(names.values()))
-            start = event["start"]
-            start_human = date_utils.date_fmt(
-                start, "medium", tz_info=date_utils.TZ_LOCAL
-            )
-            end = event["end"]
-            self.disp(A.color(
-                A.BOLD, start_human, A.RESET, " ",
-                f"({date_utils.delta2human(start, end)}) ",
-                C.A_HEADER, name
-            ))
-            if self.verbosity > 0:
-                descriptions = event.get("descriptions", [])
-                if descriptions:
-                    self.disp(descriptions[0]["description"])
-            if idx < (nb_events-1):
-                self.disp("")
-
-
-class CategoryAction(argparse.Action):
-
-    def __init__(self, option_strings, dest, nargs=None, metavar=None, **kwargs):
-        if nargs is not None or metavar is not None:
-            raise ValueError("nargs and metavar must not be used")
-        if metavar is not None:
-            metavar="TERM WIKIDATA_ID LANG"
-        if "--help" in sys.argv:
-            # FIXME: dirty workaround to have correct --help message
-            #   argparse doesn't normally allow variable number of arguments beside "+"
-            #   and "*", this workaround show METAVAR as 3 arguments were expected, while
-            #   we can actuall use 1, 2 or 3.
-            nargs = 3
-            metavar = ("TERM", "[WIKIDATA_ID]", "[LANG]")
-        else:
-            nargs = "+"
-
-        super().__init__(option_strings, dest, metavar=metavar, nargs=nargs, **kwargs)
-
-    def __call__(self, parser, namespace, values, option_string=None):
-        categories = getattr(namespace, self.dest)
-        if categories is None:
-            categories = []
-            setattr(namespace, self.dest, categories)
-
-        if not values:
-            parser.error("category values must be set")
-
-        category = {
-            "term": values[0]
-        }
-
-        if len(values) == 1:
-            pass
-        elif len(values) == 2:
-            value = values[1]
-            if value.startswith("Q"):
-                category["wikidata_id"] = value
-            else:
-                category["language"] = value
-        elif len(values) == 3:
-            __, wd, lang = values
-            category["wikidata_id"] = wd
-            category["language"] = lang
-        else:
-            parser.error("Category can't have more than 3 arguments")
-
-        categories.append(category)
-
-
-class EventBase:
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-S", "--start", type=base.date_decoder, metavar="TIME_PATTERN",
-            help=_("the start time of the event"))
-        end_group = self.parser.add_mutually_exclusive_group()
-        end_group.add_argument(
-            "-E", "--end", type=base.date_decoder, metavar="TIME_PATTERN",
-            help=_("the time of the end of the event"))
-        end_group.add_argument(
-            "-D", "--duration", help=_("duration of the event"))
-        self.parser.add_argument(
-            "-H", "--head-picture", help="URL to a picture to use as head-picture"
-        )
-        self.parser.add_argument(
-            "-d", "--description", help="plain text description the event"
-        )
-        self.parser.add_argument(
-            "-C", "--category", action=CategoryAction, dest="categories",
-            help="Category of the event"
-        )
-        self.parser.add_argument(
-            "-l", "--location", action="append", nargs="+", metavar="[KEY] VALUE",
-            help="Location metadata"
-        )
-        rsvp_group = self.parser.add_mutually_exclusive_group()
-        rsvp_group.add_argument(
-            "--rsvp", action="store_true", help=_("RSVP is requested"))
-        rsvp_group.add_argument(
-            "--rsvp_json", metavar="JSON", help=_("JSON description of RSVP form"))
-        for node_type in ("invitees", "comments", "blog", "schedule"):
-            self.parser.add_argument(
-                f"--{node_type}",
-                nargs=2,
-                metavar=("JID", "NODE"),
-                help=_("link {node_type} pubsub node").format(node_type=node_type)
-            )
-        self.parser.add_argument(
-            "-a", "--attachment", action="append", dest="attachments",
-            help=_("attach a file")
-        )
-        self.parser.add_argument("--website", help=_("website of the event"))
-        self.parser.add_argument(
-            "--status", choices=["confirmed", "tentative", "cancelled"],
-            help=_("status of the event")
-        )
-        self.parser.add_argument(
-            "-T", "--language", metavar="LANG", action="append", dest="languages",
-            help=_("main languages spoken at the event")
-        )
-        self.parser.add_argument(
-            "--wheelchair", choices=["full", "partial", "no"],
-            help=_("is the location accessible by wheelchair")
-        )
-        self.parser.add_argument(
-            "--external",
-            nargs=3,
-            metavar=("JID", "NODE", "ITEM"),
-            help=_("link to an external event")
-        )
-
-    def get_event_data(self):
-        if self.args.duration is not None:
-            if self.args.start is None:
-                self.parser.error("--start must be send if --duration is used")
-            # if duration is used, we simply add it to start time to get end time
-            self.args.end = base.date_decoder(f"{self.args.start} + {self.args.duration}")
-
-        event = {}
-        if self.args.name is not None:
-            event["name"] = {"": self.args.name}
-
-        if self.args.start is not None:
-            event["start"] = self.args.start
-
-        if self.args.end is not None:
-            event["end"] = self.args.end
-
-        if self.args.head_picture:
-            event["head-picture"] = {
-                "sources": [{
-                    "url": self.args.head_picture
-                }]
-            }
-        if self.args.description:
-            event["descriptions"] = [
-                {
-                    "type": "text",
-                    "description": self.args.description
-                }
-            ]
-        if self.args.categories:
-            event["categories"] = self.args.categories
-        if self.args.location is not None:
-            location = {}
-            for location_data in self.args.location:
-                if len(location_data) == 1:
-                    location["description"] = location_data[0]
-                else:
-                    key, *values = location_data
-                    location[key] = " ".join(values)
-            event["locations"] = [location]
-
-        if self.args.rsvp:
-            event["rsvp"] = [{}]
-        elif self.args.rsvp_json:
-            if isinstance(self.args.rsvp_elt, dict):
-                event["rsvp"] = [self.args.rsvp_json]
-            else:
-                event["rsvp"] = self.args.rsvp_json
-
-        for node_type in ("invitees", "comments", "blog", "schedule"):
-            value = getattr(self.args, node_type)
-            if value:
-                service, node = value
-                event[node_type] = {"service": service, "node": node}
-
-        if self.args.attachments:
-            attachments = event["attachments"] = []
-            for attachment in self.args.attachments:
-                attachments.append({
-                    "sources": [{"url": attachment}]
-                })
-
-        extra = {}
-
-        for arg in ("website", "status", "languages"):
-            value = getattr(self.args, arg)
-            if value is not None:
-                extra[arg] = value
-        if self.args.wheelchair is not None:
-            extra["accessibility"] = {"wheelchair": self.args.wheelchair}
-
-        if extra:
-            event["extra"] = extra
-
-        if self.args.external:
-            ext_jid, ext_node, ext_item = self.args.external
-            event["external"] = {
-                "jid": ext_jid,
-                "node": ext_node,
-                "item": ext_item
-            }
-        return event
-
-
-class Create(EventBase, base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "create",
-            use_pubsub=True,
-            help=_("create or replace event"),
-        )
-
-    def add_parser_options(self):
-        super().add_parser_options()
-        self.parser.add_argument(
-            "-i",
-            "--id",
-            default="",
-            help=_("ID of the PubSub Item"),
-        )
-        # name is mandatory here
-        self.parser.add_argument("name", help=_("name of the event"))
-
-    async def start(self):
-        if self.args.start is None:
-            self.parser.error("--start must be set")
-        event_data = self.get_event_data()
-        # we check self.args.end after get_event_data because it may be set there id
-        # --duration is used
-        if self.args.end is None:
-            self.parser.error("--end or --duration must be set")
-        try:
-            await self.host.bridge.event_create(
-                data_format.serialise(event_data),
-                self.args.id,
-                self.args.node,
-                self.args.service,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create event: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("Event created successfuly)"))
-            self.host.quit()
-
-
-class Modify(EventBase, base.CommandBase):
-    def __init__(self, host):
-        super(Modify, self).__init__(
-            host,
-            "modify",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("modify an existing event"),
-        )
-        EventBase.__init__(self)
-
-    def add_parser_options(self):
-        super().add_parser_options()
-        # name is optional here
-        self.parser.add_argument("-N", "--name", help=_("name of the event"))
-
-    async def start(self):
-        event_data = self.get_event_data()
-        try:
-            await self.host.bridge.event_modify(
-                data_format.serialise(event_data),
-                self.args.item,
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't update event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class InviteeGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            use_verbose=True,
-            help=_("get event attendance"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j", "--jid", action="append", dest="jids", default=[],
-            help=_("only retrieve RSVP from those JIDs")
-        )
-
-    async def start(self):
-        try:
-            event_data_s = await self.host.bridge.event_invitee_get(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.jids,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            event_data = data_format.deserialise(event_data_s)
-            await self.output(event_data)
-            self.host.quit()
-
-
-class InviteeSet(base.CommandBase):
-    def __init__(self, host):
-        super(InviteeSet, self).__init__(
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("set event attendance"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set"),
-        )
-
-    async def start(self):
-        # TODO: handle RSVP with XMLUI in a similar way as for `ad-hoc run`
-        fields = dict(self.args.fields) if self.args.fields else {}
-        try:
-            self.host.bridge.event_invitee_set(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                data_format.serialise(fields),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class InviteesList(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "list",
-            use_output=C.OUTPUT_DICT_DICT,
-            extra_outputs=extra_outputs,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("get event attendance"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-m",
-            "--missing",
-            action="store_true",
-            help=_("show missing people (invited but no R.S.V.P. so far)"),
-        )
-        self.parser.add_argument(
-            "-R",
-            "--no-rsvp",
-            action="store_true",
-            help=_("don't show people which gave R.S.V.P."),
-        )
-
-    def _attend_filter(self, attend, row):
-        if attend == "yes":
-            attend_color = C.A_SUCCESS
-        elif attend == "no":
-            attend_color = C.A_FAILURE
-        else:
-            attend_color = A.FG_WHITE
-        return A.color(attend_color, attend)
-
-    def _guests_filter(self, guests):
-        return "(" + str(guests) + ")" if guests else ""
-
-    def default_output(self, event_data):
-        data = []
-        attendees_yes = 0
-        attendees_maybe = 0
-        attendees_no = 0
-        attendees_missing = 0
-        guests = 0
-        guests_maybe = 0
-        for jid_, jid_data in event_data.items():
-            jid_data["jid"] = jid_
-            try:
-                guests_int = int(jid_data["guests"])
-            except (ValueError, KeyError):
-                pass
-            attend = jid_data.get("attend", "")
-            if attend == "yes":
-                attendees_yes += 1
-                guests += guests_int
-            elif attend == "maybe":
-                attendees_maybe += 1
-                guests_maybe += guests_int
-            elif attend == "no":
-                attendees_no += 1
-                jid_data["guests"] = ""
-            else:
-                attendees_missing += 1
-                jid_data["guests"] = ""
-            data.append(jid_data)
-
-        show_table = OUTPUT_OPT_TABLE in self.args.output_opts
-
-        table = common.Table.from_list_dict(
-            self.host,
-            data,
-            ("nick",) + (("jid",) if self.host.verbosity else ()) + ("attend", "guests"),
-            headers=None,
-            filters={
-                "nick": A.color(C.A_HEADER, "{}" if show_table else "{} "),
-                "jid": "{}" if show_table else "{} ",
-                "attend": self._attend_filter,
-                "guests": "{}" if show_table else self._guests_filter,
-            },
-            defaults={"nick": "", "attend": "", "guests": 1},
-        )
-        if show_table:
-            table.display()
-        else:
-            table.display_blank(show_header=False, col_sep="")
-
-        if not self.args.no_rsvp:
-            self.disp("")
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("Attendees: "),
-                    A.RESET,
-                    str(len(data)),
-                    _(" ("),
-                    C.A_SUCCESS,
-                    _("yes: "),
-                    str(attendees_yes),
-                    A.FG_WHITE,
-                    _(", maybe: "),
-                    str(attendees_maybe),
-                    ", ",
-                    C.A_FAILURE,
-                    _("no: "),
-                    str(attendees_no),
-                    A.RESET,
-                    ")",
-                )
-            )
-            self.disp(
-                A.color(C.A_SUBHEADER, _("confirmed guests: "), A.RESET, str(guests))
-            )
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("unconfirmed guests: "),
-                    A.RESET,
-                    str(guests_maybe),
-                )
-            )
-            self.disp(
-                A.color(C.A_SUBHEADER, _("total: "), A.RESET, str(guests + guests_maybe))
-            )
-        if attendees_missing:
-            self.disp("")
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("missing people (no reply): "),
-                    A.RESET,
-                    str(attendees_missing),
-                )
-            )
-
-    async def start(self):
-        if self.args.no_rsvp and not self.args.missing:
-            self.parser.error(_("you need to use --missing if you use --no-rsvp"))
-        if not self.args.missing:
-            prefilled = {}
-        else:
-            # we get prefilled data with all people
-            try:
-                affiliations = await self.host.bridge.ps_node_affiliations_get(
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(f"can't get node affiliations: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                # we fill all affiliations with empty data, answered one will be filled
-                # below. We only consider people with "publisher" affiliation as invited,
-                # creators are not, and members can just observe
-                prefilled = {
-                    jid_: {}
-                    for jid_, affiliation in affiliations.items()
-                    if affiliation in ("publisher",)
-                }
-
-        try:
-            event_data = await self.host.bridge.event_invitees_list(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            # we fill nicknames and keep only requested people
-
-        if self.args.no_rsvp:
-            for jid_ in event_data:
-                # if there is a jid in event_data it must be there in prefilled too
-                # otherwie somebody is not on the invitees list
-                try:
-                    del prefilled[jid_]
-                except KeyError:
-                    self.disp(
-                        A.color(
-                            C.A_WARNING,
-                            f"We got a RSVP from somebody who was not in invitees "
-                            f"list: {jid_}",
-                        ),
-                        error=True,
-                    )
-        else:
-            # we replace empty dicts for existing people with R.S.V.P. data
-            prefilled.update(event_data)
-
-            # we get nicknames for everybody, make it easier for organisers
-        for jid_, data in prefilled.items():
-            id_data = await self.host.bridge.identity_get(jid_, [], True, self.profile)
-            id_data = data_format.deserialise(id_data)
-            data["nick"] = id_data["nicknames"][0]
-
-        await self.output(prefilled)
-        self.host.quit()
-
-
-class InviteeInvite(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "invite",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("invite someone to the event through email"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            action="append",
-            default=[],
-            help="email(s) to send the invitation to",
-        )
-        self.parser.add_argument(
-            "-N",
-            "--name",
-            default="",
-            help="name of the invitee",
-        )
-        self.parser.add_argument(
-            "-H",
-            "--host-name",
-            default="",
-            help="name of the host",
-        )
-        self.parser.add_argument(
-            "-l",
-            "--lang",
-            default="",
-            help="main language spoken by the invitee",
-        )
-        self.parser.add_argument(
-            "-U",
-            "--url-template",
-            default="",
-            help="template to construct the URL",
-        )
-        self.parser.add_argument(
-            "-S",
-            "--subject",
-            default="",
-            help="subject of the invitation email (default: generic subject)",
-        )
-        self.parser.add_argument(
-            "-b",
-            "--body",
-            default="",
-            help="body of the invitation email (default: generic body)",
-        )
-
-    async def start(self):
-        email = self.args.email[0] if self.args.email else None
-        emails_extra = self.args.email[1:]
-
-        try:
-            await self.host.bridge.event_invite_by_email(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                email,
-                emails_extra,
-                self.args.name,
-                self.args.host_name,
-                self.args.lang,
-                self.args.url_template,
-                self.args.subject,
-                self.args.body,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Invitee(base.CommandBase):
-    subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite)
-
-    def __init__(self, host):
-        super(Invitee, self).__init__(
-            host, "invitee", use_profile=False, help=_("manage invities")
-        )
-
-
-class Event(base.CommandBase):
-    subcommands = (Get, Create, Modify, Invitee)
-
-    def __init__(self, host):
-        super(Event, self).__init__(
-            host, "event", use_profile=False, help=_("event management")
-        )
--- a/libervia/frontends/jp/cmd_file.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1108 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from . import xmlui_manager
-import sys
-import os
-import os.path
-import tarfile
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import common
-from libervia.frontends.tools import jid
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import utils
-from urllib.parse import urlparse
-from pathlib import Path
-import tempfile
-import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
-import json
-
-__commands__ = ["File"]
-DEFAULT_DEST = "downloaded_file"
-
-
-class Send(base.CommandBase):
-    def __init__(self, host):
-        super(Send, self).__init__(
-            host,
-            "send",
-            use_progress=True,
-            use_verbose=True,
-            help=_("send a file to a contact"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "files", type=str, nargs="+", metavar="file", help=_("a list of file")
-        )
-        self.parser.add_argument("jid", help=_("the destination jid"))
-        self.parser.add_argument(
-            "-b", "--bz2", action="store_true", help=_("make a bzip2 tarball")
-        )
-        self.parser.add_argument(
-            "-d",
-            "--path",
-            help=("path to the directory where the file must be stored"),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            help=("namespace of the file"),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=("name to use (DEFAULT: use source file name)"),
-        )
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the file transfer")
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File copy started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File sent successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        if error_msg == C.PROGRESS_ERROR_DECLINED:
-            self.disp(_("The file has been refused by your contact"))
-        else:
-            self.disp(_("Error while sending file: {}").format(error_msg), error=True)
-
-    async def got_id(self, data, file_):
-        """Called when a progress id has been received
-
-        @param pid(unicode): progress id
-        @param file_(str): file path
-        """
-        # FIXME: this show progress only for last progress_id
-        self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1)
-        try:
-            await self.set_progress_id(data["progress"])
-        except KeyError:
-            # TODO: if 'xmlui' key is present, manage xmlui message display
-            self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True)
-            self.host.quit(2)
-
-    async def start(self):
-        for file_ in self.args.files:
-            if not os.path.exists(file_):
-                self.disp(
-                    _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-            if not self.args.bz2 and os.path.isdir(file_):
-                self.disp(
-                    _(
-                        "{file_} is a dir! Please send files inside or use compression"
-                    ).format(file_=repr(file_))
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-
-        extra = {}
-        if self.args.path:
-            extra["path"] = self.args.path
-        if self.args.namespace:
-            extra["namespace"] = self.args.namespace
-        if self.args.encrypt:
-            extra["encrypted"] = True
-
-        if self.args.bz2:
-            with tempfile.NamedTemporaryFile("wb", delete=False) as buf:
-                self.host.add_on_quit_callback(os.unlink, buf.name)
-                self.disp(_("bz2 is an experimental option, use with caution"))
-                # FIXME: check free space
-                self.disp(_("Starting compression, please wait..."))
-                sys.stdout.flush()
-                bz2 = tarfile.open(mode="w:bz2", fileobj=buf)
-                archive_name = "{}.tar.bz2".format(
-                    os.path.basename(self.args.files[0]) or "compressed_files"
-                )
-                for file_ in self.args.files:
-                    self.disp(_("Adding {}").format(file_), 1)
-                    bz2.add(file_)
-                bz2.close()
-                self.disp(_("Done !"), 1)
-
-                try:
-                    send_data = await self.host.bridge.file_send(
-                        self.args.jid,
-                        buf.name,
-                        self.args.name or archive_name,
-                        "",
-                        data_format.serialise(extra),
-                        self.profile,
-                    )
-                except Exception as e:
-                    self.disp(f"can't send file: {e}", error=True)
-                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                else:
-                    await self.got_id(send_data, file_)
-        else:
-            for file_ in self.args.files:
-                path = os.path.abspath(file_)
-                try:
-                    send_data = await self.host.bridge.file_send(
-                        self.args.jid,
-                        path,
-                        self.args.name,
-                        "",
-                        data_format.serialise(extra),
-                        self.profile,
-                    )
-                except Exception as e:
-                    self.disp(f"can't send file {file_!r}: {e}", error=True)
-                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                else:
-                    await self.got_id(send_data, file_)
-
-
-class Request(base.CommandBase):
-    def __init__(self, host):
-        super(Request, self).__init__(
-            host,
-            "request",
-            use_progress=True,
-            use_verbose=True,
-            help=_("request a file from a contact"),
-        )
-
-    @property
-    def filename(self):
-        return self.args.name or self.args.hash or "output"
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", help=_("the destination jid"))
-        self.parser.add_argument(
-            "-D",
-            "--dest",
-            help=_(
-                "destination path where the file will be saved (default: "
-                "[current_dir]/[name|hash])"
-            ),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=_("name of the file"),
-        )
-        self.parser.add_argument(
-            "-H",
-            "--hash",
-            default="",
-            help=_("hash of the file"),
-        )
-        self.parser.add_argument(
-            "-a",
-            "--hash-algo",
-            default="sha-256",
-            help=_("hash algorithm use for --hash (default: sha-256)"),
-        )
-        self.parser.add_argument(
-            "-d",
-            "--path",
-            help=("path to the directory containing the file"),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            help=("namespace of the file"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("overwrite existing file without confirmation"),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File copy started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File received successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        if error_msg == C.PROGRESS_ERROR_DECLINED:
-            self.disp(_("The file request has been refused"))
-        else:
-            self.disp(_("Error while requesting file: {}").format(error_msg), error=True)
-
-    async def start(self):
-        if not self.args.name and not self.args.hash:
-            self.parser.error(_("at least one of --name or --hash must be provided"))
-        if self.args.dest:
-            path = os.path.abspath(os.path.expanduser(self.args.dest))
-            if os.path.isdir(path):
-                path = os.path.join(path, self.filename)
-        else:
-            path = os.path.abspath(self.filename)
-
-        if os.path.exists(path) and not self.args.force:
-            message = _("File {path} already exists! Do you want to overwrite?").format(
-                path=path
-            )
-            await self.host.confirm_or_quit(message, _("file request cancelled"))
-
-        self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
-        extra = {}
-        if self.args.path:
-            extra["path"] = self.args.path
-        if self.args.namespace:
-            extra["namespace"] = self.args.namespace
-        try:
-            progress_id = await self.host.bridge.file_jingle_request(
-                self.full_dest_jid,
-                path,
-                self.args.name,
-                self.args.hash,
-                self.args.hash_algo if self.args.hash else "",
-                extra,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't request file: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.set_progress_id(progress_id)
-
-
-class Receive(base.CommandAnswering):
-    def __init__(self, host):
-        super(Receive, self).__init__(
-            host,
-            "receive",
-            use_progress=True,
-            use_verbose=True,
-            help=_("wait for a file to be sent by a contact"),
-        )
-        self._overwrite_refused = False  # True when one overwrite as already been refused
-        self.action_callbacks = {
-            C.META_TYPE_FILE: self.on_file_action,
-            C.META_TYPE_OVERWRITE: self.on_overwrite_action,
-            C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action,
-        }
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jids",
-            nargs="*",
-            help=_("jids accepted (accept everything if none is specified)"),
-        )
-        self.parser.add_argument(
-            "-m",
-            "--multiple",
-            action="store_true",
-            help=_("accept multiple files (you'll have to stop manually)"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_(
-                "force overwritting of existing files (/!\\ name is choosed by sender)"
-            ),
-        )
-        self.parser.add_argument(
-            "--path",
-            default=".",
-            metavar="DIR",
-            help=_("destination path (default: working directory)"),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File copy started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File received successfully"), 2)
-        if metadata.get("hash_verified", False):
-            try:
-                self.disp(
-                    _("hash checked: {metadata['hash_algo']}:{metadata['hash']}"), 1
-                )
-            except KeyError:
-                self.disp(_("hash is checked but hash value is missing", 1), error=True)
-        else:
-            self.disp(_("hash can't be verified"), 1)
-
-    async def on_progress_error(self, e):
-        self.disp(_("Error while receiving file: {e}").format(e=e), error=True)
-
-    def get_xmlui_id(self, action_data):
-        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
-        #        should be available in the futur
-        # TODO: XMLUI module
-        try:
-            xml_ui = action_data["xmlui"]
-        except KeyError:
-            self.disp(_("Action has no XMLUI"), 1)
-        else:
-            ui = ET.fromstring(xml_ui.encode("utf-8"))
-            xmlui_id = ui.get("submit")
-            if not xmlui_id:
-                self.disp(_("Invalid XMLUI received"), error=True)
-            return xmlui_id
-
-    async def on_file_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            return self.host.quit_from_signal(1)
-        try:
-            from_jid = jid.JID(action_data["from_jid"])
-        except KeyError:
-            self.disp(_("Ignoring action without from_jid data"), 1)
-            return
-        try:
-            progress_id = action_data["progress_id"]
-        except KeyError:
-            self.disp(_("ignoring action without progress id"), 1)
-            return
-
-        if not self.bare_jids or from_jid.bare in self.bare_jids:
-            if self._overwrite_refused:
-                self.disp(_("File refused because overwrite is needed"), error=True)
-                await self.host.bridge.action_launch(
-                    xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}),
-                    profile_key=profile
-                )
-                return self.host.quit_from_signal(2)
-            await self.set_progress_id(progress_id)
-            xmlui_data = {"path": self.path}
-            await self.host.bridge.action_launch(
-                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-            )
-
-    async def on_overwrite_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            return self.host.quit_from_signal(1)
-        try:
-            progress_id = action_data["progress_id"]
-        except KeyError:
-            self.disp(_("ignoring action without progress id"), 1)
-            return
-        self.disp(_("Overwriting needed"), 1)
-
-        if progress_id == self.progress_id:
-            if self.args.force:
-                self.disp(_("Overwrite accepted"), 2)
-            else:
-                self.disp(_("Refused to overwrite"), 2)
-                self._overwrite_refused = True
-
-            xmlui_data = {"answer": C.bool_const(self.args.force)}
-            await self.host.bridge.action_launch(
-                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-            )
-
-    async def on_not_in_roster_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            return self.host.quit_from_signal(1)
-        try:
-            from_jid = jid.JID(action_data["from_jid"])
-        except ValueError:
-            self.disp(
-                _('invalid "from_jid" value received, ignoring: {value}').format(
-                    value=from_jid
-                ),
-                error=True,
-            )
-            return
-        except KeyError:
-            self.disp(_('ignoring action without "from_jid" value'), error=True)
-            return
-        self.disp(_("Confirmation needed for request from an entity not in roster"), 1)
-
-        if from_jid.bare in self.bare_jids:
-            # if the sender is expected, we can confirm the session
-            confirmed = True
-            self.disp(_("Sender confirmed because she or he is explicitly expected"), 1)
-        else:
-            xmlui = xmlui_manager.create(self.host, action_data["xmlui"])
-            confirmed = await self.host.confirm(xmlui.dlg.message)
-
-        xmlui_data = {"answer": C.bool_const(confirmed)}
-        await self.host.bridge.action_launch(
-            xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-        )
-        if not confirmed and not self.args.multiple:
-            self.disp(_("Session refused for {from_jid}").format(from_jid=from_jid))
-            self.host.quit_from_signal(0)
-
-    async def start(self):
-        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
-        self.path = os.path.abspath(self.args.path)
-        if not os.path.isdir(self.path):
-            self.disp(_("Given path is not a directory !", error=True))
-            self.host.quit(C.EXIT_BAD_ARG)
-        if self.args.multiple:
-            self.host.quit_on_progress_end = False
-        self.disp(_("waiting for incoming file request"), 2)
-        await self.start_answering()
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        super(Get, self).__init__(
-            host,
-            "get",
-            use_progress=True,
-            use_verbose=True,
-            help=_("download a file from URI"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-o",
-            "--dest-file",
-            type=str,
-            default="",
-            help=_("destination file (DEFAULT: filename from URL)"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("overwrite existing file without confirmation"),
-        )
-        self.parser.add_argument(
-            "attachment", type=str,
-            help=_("URI of the file to retrieve or JSON of the whole attachment")
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File download started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File downloaded successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        self.disp(_("Error while downloading file: {}").format(error_msg), error=True)
-
-    async def got_id(self, data):
-        """Called when a progress id has been received"""
-        try:
-            await self.set_progress_id(data["progress"])
-        except KeyError:
-            if "xmlui" in data:
-                ui = xmlui_manager.create(self.host, data["xmlui"])
-                await ui.show()
-            else:
-                self.disp(_("Can't download file"), error=True)
-            self.host.quit(C.EXIT_ERROR)
-
-    async def start(self):
-        try:
-            attachment = json.loads(self.args.attachment)
-        except json.JSONDecodeError:
-            attachment = {"uri": self.args.attachment}
-        dest_file = self.args.dest_file
-        if not dest_file:
-            try:
-                dest_file = attachment["name"].replace("/", "-").strip()
-            except KeyError:
-                try:
-                    dest_file = Path(urlparse(attachment["uri"]).path).name.strip()
-                except KeyError:
-                    pass
-            if not dest_file:
-                dest_file = "downloaded_file"
-
-        dest_file = Path(dest_file).expanduser().resolve()
-        if dest_file.exists() and not self.args.force:
-            message = _("File {path} already exists! Do you want to overwrite?").format(
-                path=dest_file
-            )
-            await self.host.confirm_or_quit(message, _("file download cancelled"))
-
-        options = {}
-
-        try:
-            download_data_s = await self.host.bridge.file_download(
-                data_format.serialise(attachment),
-                str(dest_file),
-                data_format.serialise(options),
-                self.profile,
-            )
-            download_data = data_format.deserialise(download_data_s)
-        except Exception as e:
-            self.disp(f"error while trying to download a file: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.got_id(download_data)
-
-
-class Upload(base.CommandBase):
-    def __init__(self, host):
-        super(Upload, self).__init__(
-            host, "upload", use_progress=True, use_verbose=True, help=_("upload a file")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("encrypt file using AES-GCM"),
-        )
-        self.parser.add_argument("file", type=str, help=_("file to upload"))
-        self.parser.add_argument(
-            "jid",
-            nargs="?",
-            help=_("jid of upload component (nothing to autodetect)"),
-        )
-        self.parser.add_argument(
-            "--ignore-tls-errors",
-            action="store_true",
-            help=_(r"ignore invalide TLS certificate (/!\ Dangerous /!\)"),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File upload started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File uploaded successfully"), 2)
-        try:
-            url = metadata["url"]
-        except KeyError:
-            self.disp("download URL not found in metadata")
-        else:
-            self.disp(_("URL to retrieve the file:"), 1)
-            # XXX: url is displayed alone on a line to make parsing easier
-            self.disp(url)
-
-    async def on_progress_error(self, error_msg):
-        self.disp(_("Error while uploading file: {}").format(error_msg), error=True)
-
-    async def got_id(self, data, file_):
-        """Called when a progress id has been received
-
-        @param pid(unicode): progress id
-        @param file_(str): file path
-        """
-        try:
-            await self.set_progress_id(data["progress"])
-        except KeyError:
-            if "xmlui" in data:
-                ui = xmlui_manager.create(self.host, data["xmlui"])
-                await ui.show()
-            else:
-                self.disp(_("Can't upload file"), error=True)
-            self.host.quit(C.EXIT_ERROR)
-
-    async def start(self):
-        file_ = self.args.file
-        if not os.path.exists(file_):
-            self.disp(
-                _("file {file_} doesn't exist !").format(file_=repr(file_)), error=True
-            )
-            self.host.quit(C.EXIT_BAD_ARG)
-        if os.path.isdir(file_):
-            self.disp(_("{file_} is a dir! Can't upload a dir").format(file_=repr(file_)))
-            self.host.quit(C.EXIT_BAD_ARG)
-
-        if self.args.jid is None:
-            self.full_dest_jid = ""
-        else:
-            self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
-
-        options = {}
-        if self.args.ignore_tls_errors:
-            options["ignore_tls_errors"] = True
-        if self.args.encrypt:
-            options["encryption"] = C.ENC_AES_GCM
-
-        path = os.path.abspath(file_)
-        try:
-            upload_data = await self.host.bridge.file_upload(
-                path,
-                "",
-                self.full_dest_jid,
-                data_format.serialise(options),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"error while trying to upload a file: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.got_id(upload_data, file_)
-
-
-class ShareAffiliationsSet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareAffiliationsSet, self).__init__(
-            host,
-            "set",
-            use_output=C.OUTPUT_DICT,
-            help=_("set affiliations for a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "-a",
-            "--affiliation",
-            dest="affiliations",
-            metavar=("JID", "AFFILIATION"),
-            required=True,
-            action="append",
-            nargs=2,
-            help=_("entity/affiliation couple(s)"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of file sharing entity"),
-        )
-
-    async def start(self):
-        affiliations = dict(self.args.affiliations)
-        try:
-            affiliations = await self.host.bridge.fis_affiliations_set(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                affiliations,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class ShareAffiliationsGet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareAffiliationsGet, self).__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            help=_("retrieve affiliations of a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of sharing entity"),
-        )
-
-    async def start(self):
-        try:
-            affiliations = await self.host.bridge.fis_affiliations_get(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(affiliations)
-            self.host.quit()
-
-
-class ShareAffiliations(base.CommandBase):
-    subcommands = (ShareAffiliationsGet, ShareAffiliationsSet)
-
-    def __init__(self, host):
-        super(ShareAffiliations, self).__init__(
-            host, "affiliations", use_profile=False, help=_("affiliations management")
-        )
-
-
-class ShareConfigurationSet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareConfigurationSet, self).__init__(
-            host,
-            "set",
-            use_output=C.OUTPUT_DICT,
-            help=_("set configuration for a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            required=True,
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set (required)"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of file sharing entity"),
-        )
-
-    async def start(self):
-        configuration = dict(self.args.fields)
-        try:
-            configuration = await self.host.bridge.fis_configuration_set(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                configuration,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set configuration: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class ShareConfigurationGet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareConfigurationGet, self).__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            help=_("retrieve configuration of a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of sharing entity"),
-        )
-
-    async def start(self):
-        try:
-            configuration = await self.host.bridge.fis_configuration_get(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get configuration: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(configuration)
-            self.host.quit()
-
-
-class ShareConfiguration(base.CommandBase):
-    subcommands = (ShareConfigurationGet, ShareConfigurationSet)
-
-    def __init__(self, host):
-        super(ShareConfiguration, self).__init__(
-            host,
-            "configuration",
-            use_profile=False,
-            help=_("file sharing node configuration"),
-        )
-
-
-class ShareList(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(ShareList, self).__init__(
-            host,
-            "list",
-            use_output=C.OUTPUT_LIST_DICT,
-            extra_outputs=extra_outputs,
-            help=_("retrieve files shared by an entity"),
-            use_verbose=True,
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-d",
-            "--path",
-            default="",
-            help=_("path to the directory containing the files"),
-        )
-        self.parser.add_argument(
-            "jid",
-            nargs="?",
-            default="",
-            help=_("jid of sharing entity (nothing to check our own jid)"),
-        )
-
-    def _name_filter(self, name, row):
-        if row.type == C.FILE_TYPE_DIRECTORY:
-            return A.color(C.A_DIRECTORY, name)
-        elif row.type == C.FILE_TYPE_FILE:
-            return A.color(C.A_FILE, name)
-        else:
-            self.disp(_("unknown file type: {type}").format(type=row.type), error=True)
-            return name
-
-    def _size_filter(self, size, row):
-        if not size:
-            return ""
-        return A.color(A.BOLD, utils.get_human_size(size))
-
-    def default_output(self, files_data):
-        """display files a way similar to ls"""
-        files_data.sort(key=lambda d: d["name"].lower())
-        show_header = False
-        if self.verbosity == 0:
-            keys = headers = ("name", "type")
-        elif self.verbosity == 1:
-            keys = headers = ("name", "type", "size")
-        elif self.verbosity > 1:
-            show_header = True
-            keys = ("name", "type", "size", "file_hash")
-            headers = ("name", "type", "size", "hash")
-        table = common.Table.from_list_dict(
-            self.host,
-            files_data,
-            keys=keys,
-            headers=headers,
-            filters={"name": self._name_filter, "size": self._size_filter},
-            defaults={"size": "", "file_hash": ""},
-        )
-        table.display_blank(show_header=show_header, hide_cols=["type"])
-
-    async def start(self):
-        try:
-            files_data = await self.host.bridge.fis_list(
-                self.args.jid,
-                self.args.path,
-                {},
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve shared files: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        await self.output(files_data)
-        self.host.quit()
-
-
-class SharePath(base.CommandBase):
-    def __init__(self, host):
-        super(SharePath, self).__init__(
-            host, "path", help=_("share a file or directory"), use_verbose=True
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=_("virtual name to use (default: use directory/file name)"),
-        )
-        perm_group = self.parser.add_mutually_exclusive_group()
-        perm_group.add_argument(
-            "-j",
-            "--jid",
-            metavar="JID",
-            action="append",
-            dest="jids",
-            default=[],
-            help=_("jid of contacts allowed to retrieve the files"),
-        )
-        perm_group.add_argument(
-            "--public",
-            action="store_true",
-            help=_(
-                r"share publicly the file(s) (/!\ *everybody* will be able to access "
-                r"them)"
-            ),
-        )
-        self.parser.add_argument(
-            "path",
-            help=_("path to a file or directory to share"),
-        )
-
-    async def start(self):
-        self.path = os.path.abspath(self.args.path)
-        if self.args.public:
-            access = {"read": {"type": "public"}}
-        else:
-            jids = self.args.jids
-            if jids:
-                access = {"read": {"type": "whitelist", "jids": jids}}
-            else:
-                access = {}
-        try:
-            name = await self.host.bridge.fis_share_path(
-                self.args.name,
-                self.path,
-                json.dumps(access, ensure_ascii=False),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't share path: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _('{path} shared under the name "{name}"').format(
-                    path=self.path, name=name
-                )
-            )
-            self.host.quit()
-
-
-class ShareInvite(base.CommandBase):
-    def __init__(self, host):
-        super(ShareInvite, self).__init__(
-            host, "invite", help=_("send invitation for a shared repository")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=_("name of the repository"),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            choices=["files", "photos"],
-            default="files",
-            help=_("type of the repository"),
-        )
-        self.parser.add_argument(
-            "-T",
-            "--thumbnail",
-            help=_("https URL of a image to use as thumbnail"),
-        )
-        self.parser.add_argument(
-            "service",
-            help=_("jid of the file sharing service hosting the repository"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the person to invite"),
-        )
-
-    async def start(self):
-        self.path = os.path.normpath(self.args.path) if self.args.path else ""
-        extra = {}
-        if self.args.thumbnail is not None:
-            if not self.args.thumbnail.startswith("http"):
-                self.parser.error(_("only http(s) links are allowed with --thumbnail"))
-            else:
-                extra["thumb_url"] = self.args.thumbnail
-        try:
-            await self.host.bridge.fis_invite(
-                self.args.jid,
-                self.args.service,
-                self.args.type,
-                self.args.namespace,
-                self.path,
-                self.args.name,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("invitation sent to {jid}").format(jid=self.args.jid))
-            self.host.quit()
-
-
-class Share(base.CommandBase):
-    subcommands = (
-        ShareList,
-        SharePath,
-        ShareInvite,
-        ShareAffiliations,
-        ShareConfiguration,
-    )
-
-    def __init__(self, host):
-        super(Share, self).__init__(
-            host, "share", use_profile=False, help=_("files sharing management")
-        )
-
-
-class File(base.CommandBase):
-    subcommands = (Send, Request, Receive, Get, Upload, Share)
-
-    def __init__(self, host):
-        super(File, self).__init__(
-            host, "file", use_profile=False, help=_("files sending/receiving/management")
-        )
--- a/libervia/frontends/jp/cmd_forums.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,181 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import common
-from libervia.backend.tools.common.ansi import ANSI as A
-import codecs
-import json
-
-__commands__ = ["Forums"]
-
-FORUMS_TMP_DIR = "forums"
-
-
-class Edit(base.CommandBase, common.BaseEdit):
-    use_items = False
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_pubsub=True,
-            use_draft=True,
-            use_verbose=True,
-            help=_("edit forums"),
-        )
-        common.BaseEdit.__init__(self, self.host, FORUMS_TMP_DIR)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            default="",
-            help=_("forum key (DEFAULT: default forums)"),
-        )
-
-    def get_tmp_suff(self):
-        """return suffix used for content file"""
-        return "json"
-
-    async def publish(self, forums_raw):
-        try:
-            await self.host.bridge.forums_set(
-                forums_raw,
-                self.args.service,
-                self.args.node,
-                self.args.key,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set forums: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("forums have been edited"), 1)
-            self.host.quit()
-
-    async def start(self):
-        try:
-            forums_json = await self.host.bridge.forums_get(
-                self.args.service,
-                self.args.node,
-                self.args.key,
-                self.profile,
-            )
-        except Exception as e:
-            if e.classname == "NotFound":
-                forums_json = ""
-            else:
-                self.disp(f"can't get node configuration: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        content_file_obj, content_file_path = self.get_tmp_file()
-        forums_json = forums_json.strip()
-        if forums_json:
-            # we loads and dumps to have pretty printed json
-            forums = json.loads(forums_json)
-            # cf. https://stackoverflow.com/a/18337754
-            f = codecs.getwriter("utf-8")(content_file_obj)
-            json.dump(forums, f, ensure_ascii=False, indent=4)
-            content_file_obj.seek(0)
-        await self.run_editor("forums_editor_args", content_file_path, content_file_obj)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_COMPLEX,
-            extra_outputs=extra_outputs,
-            use_pubsub=True,
-            use_verbose=True,
-            help=_("get forums structure"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            default="",
-            help=_("forum key (DEFAULT: default forums)"),
-        )
-
-    def default_output(self, forums, level=0):
-        for forum in forums:
-            keys = list(forum.keys())
-            keys.sort()
-            try:
-                keys.remove("title")
-            except ValueError:
-                pass
-            else:
-                keys.insert(0, "title")
-            try:
-                keys.remove("sub-forums")
-            except ValueError:
-                pass
-            else:
-                keys.append("sub-forums")
-
-            for key in keys:
-                value = forum[key]
-                if key == "sub-forums":
-                    self.default_output(value, level + 1)
-                else:
-                    if self.host.verbosity < 1 and key != "title":
-                        continue
-                    head_color = C.A_LEVEL_COLORS[level % len(C.A_LEVEL_COLORS)]
-                    self.disp(
-                        A.color(level * 4 * " ", head_color, key, A.RESET, ": ", value)
-                    )
-
-    async def start(self):
-        try:
-            forums_raw = await self.host.bridge.forums_get(
-                self.args.service,
-                self.args.node,
-                self.args.key,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get forums: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not forums_raw:
-                self.disp(_("no schema found"), 1)
-                self.host.quit(1)
-            forums = json.loads(forums_raw)
-            await self.output(forums)
-            self.host.quit()
-
-
-class Forums(base.CommandBase):
-    subcommands = (Get, Edit)
-
-    def __init__(self, host):
-        super(Forums, self).__init__(
-            host, "forums", use_profile=False, help=_("Forums structure edition")
-        )
--- a/libervia/frontends/jp/cmd_identity.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,111 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools.common import data_format
-
-__commands__ = ["Identity"]
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_verbose=True,
-            help=_("get identity data"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--no-cache", action="store_true", help=_("do no use cached values")
-        )
-        self.parser.add_argument(
-            "jid", help=_("entity to check")
-        )
-
-    async def start(self):
-        jid_ = (await self.host.check_jids([self.args.jid]))[0]
-        try:
-            data = await self.host.bridge.identity_get(
-                jid_,
-                [],
-                not self.args.no_cache,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't get identity data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            data = data_format.deserialise(data)
-            await self.output(data)
-            self.host.quit()
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        super(Set, self).__init__(host, "set", help=_("update identity data"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n",
-            "--nickname",
-            action="append",
-            metavar="NICKNAME",
-            dest="nicknames",
-            help=_("nicknames of the entity"),
-        )
-        self.parser.add_argument(
-            "-d",
-            "--description",
-            help=_("description of the entity"),
-        )
-
-    async def start(self):
-        id_data = {}
-        for field in ("nicknames", "description"):
-            value = getattr(self.args, field)
-            if value is not None:
-                id_data[field] = value
-        if not id_data:
-            self.parser.error("At least one metadata must be set")
-        try:
-            self.host.bridge.identity_set(
-                data_format.serialise(id_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set identity data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Identity(base.CommandBase):
-    subcommands = (Get, Set)
-
-    def __init__(self, host):
-        super(Identity, self).__init__(
-            host, "identity", use_profile=False, help=_("identity management")
-        )
--- a/libervia/frontends/jp/cmd_info.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,386 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from pprint import pformat
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format, date_utils
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.frontends.jp import common
-from libervia.frontends.jp.constants import Const as C
-
-from . import base
-
-__commands__ = ["Info"]
-
-
-class Disco(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(Disco, self).__init__(
-            host,
-            "disco",
-            use_output="complex",
-            extra_outputs=extra_outputs,
-            help=_("service discovery"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", help=_("entity to discover"))
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            type=str,
-            choices=("infos", "items", "both", "external", "all"),
-            default="all",
-            help=_("type of data to discover"),
-        )
-        self.parser.add_argument("-n", "--node", default="", help=_("node to use"))
-        self.parser.add_argument(
-            "-C",
-            "--no-cache",
-            dest="use_cache",
-            action="store_false",
-            help=_("ignore cache"),
-        )
-
-    def default_output(self, data):
-        features = data.get("features", [])
-        identities = data.get("identities", [])
-        extensions = data.get("extensions", {})
-        items = data.get("items", [])
-        external = data.get("external", [])
-
-        identities_table = common.Table(
-            self.host,
-            identities,
-            headers=(_("category"), _("type"), _("name")),
-            use_buffer=True,
-        )
-
-        extensions_tpl = []
-        extensions_types = list(extensions.keys())
-        extensions_types.sort()
-        for type_ in extensions_types:
-            fields = []
-            for field in extensions[type_]:
-                field_lines = []
-                data, values = field
-                data_keys = list(data.keys())
-                data_keys.sort()
-                for key in data_keys:
-                    field_lines.append(
-                        A.color("\t", C.A_SUBHEADER, key, A.RESET, ": ", data[key])
-                    )
-                if len(values) == 1:
-                    field_lines.append(
-                        A.color(
-                            "\t",
-                            C.A_SUBHEADER,
-                            "value",
-                            A.RESET,
-                            ": ",
-                            values[0] or (A.BOLD + "UNSET"),
-                        )
-                    )
-                elif len(values) > 1:
-                    field_lines.append(
-                        A.color("\t", C.A_SUBHEADER, "values", A.RESET, ": ")
-                    )
-
-                    for value in values:
-                        field_lines.append(A.color("\t  - ", A.BOLD, value))
-                fields.append("\n".join(field_lines))
-            extensions_tpl.append(
-                "{type_}\n{fields}".format(type_=type_, fields="\n\n".join(fields))
-            )
-
-        items_table = common.Table(
-            self.host, items, headers=(_("entity"), _("node"), _("name")), use_buffer=True
-        )
-
-        template = []
-        fmt_kwargs = {}
-        if features:
-            template.append(A.color(C.A_HEADER, _("Features")) + "\n\n{features}")
-        if identities:
-            template.append(A.color(C.A_HEADER, _("Identities")) + "\n\n{identities}")
-        if extensions:
-            template.append(A.color(C.A_HEADER, _("Extensions")) + "\n\n{extensions}")
-        if items:
-            template.append(A.color(C.A_HEADER, _("Items")) + "\n\n{items}")
-        if external:
-            fmt_lines = []
-            for e in external:
-                data = {k: e[k] for k in sorted(e)}
-                host = data.pop("host")
-                type_ = data.pop("type")
-                fmt_lines.append(A.color(
-                    "\t",
-                    C.A_SUBHEADER,
-                    host,
-                    " ",
-                    A.RESET,
-                    "[",
-                    C.A_LEVEL_COLORS[1],
-                    type_,
-                    A.RESET,
-                    "]",
-                ))
-                extended = data.pop("extended", None)
-                for key, value in data.items():
-                    fmt_lines.append(A.color(
-                        "\t\t",
-                        C.A_LEVEL_COLORS[2],
-                        f"{key}: ",
-                        C.A_LEVEL_COLORS[3],
-                        str(value)
-                    ))
-                if extended:
-                    fmt_lines.append(A.color(
-                        "\t\t",
-                        C.A_HEADER,
-                        "extended",
-                    ))
-                    nb_extended = len(extended)
-                    for idx, form_data in enumerate(extended):
-                        namespace = form_data.get("namespace")
-                        if namespace:
-                            fmt_lines.append(A.color(
-                                "\t\t",
-                                C.A_LEVEL_COLORS[2],
-                                "namespace: ",
-                                C.A_LEVEL_COLORS[3],
-                                A.BOLD,
-                                namespace
-                            ))
-                        for field_data in form_data["fields"]:
-                            name = field_data.get("name")
-                            if not name:
-                                continue
-                            field_type = field_data.get("type")
-                            if "multi" in field_type:
-                                value = ", ".join(field_data.get("values") or [])
-                            else:
-                                value = field_data.get("value")
-                                if value is None:
-                                    continue
-                                if field_type == "boolean":
-                                    value = C.bool(value)
-                            fmt_lines.append(A.color(
-                                "\t\t",
-                                C.A_LEVEL_COLORS[2],
-                                f"{name}: ",
-                                C.A_LEVEL_COLORS[3],
-                                A.BOLD,
-                                str(value)
-                            ))
-                        if nb_extended>1 and idx < nb_extended-1:
-                            fmt_lines.append("\n")
-
-                fmt_lines.append("\n")
-
-            template.append(
-                A.color(C.A_HEADER, _("External")) + "\n\n{external_formatted}"
-            )
-            fmt_kwargs["external_formatted"] = "\n".join(fmt_lines)
-
-        print(
-            "\n\n".join(template).format(
-                features="\n".join(features),
-                identities=identities_table.display().string,
-                extensions="\n".join(extensions_tpl),
-                items=items_table.display().string,
-                **fmt_kwargs,
-            )
-        )
-
-    async def start(self):
-        infos_requested = self.args.type in ("infos", "both", "all")
-        items_requested = self.args.type in ("items", "both", "all")
-        exter_requested = self.args.type in ("external", "all")
-        if self.args.node:
-            if self.args.type == "external":
-                self.parser.error(
-                    '--node can\'t be used with discovery of external services '
-                    '(--type="external")'
-                )
-            else:
-                exter_requested = False
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        data = {}
-
-        # infos
-        if infos_requested:
-            try:
-                infos = await self.host.bridge.disco_infos(
-                    jid,
-                    node=self.args.node,
-                    use_cache=self.args.use_cache,
-                    profile_key=self.host.profile,
-                )
-            except Exception as e:
-                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            else:
-                features, identities, extensions = infos
-                features.sort()
-                identities.sort(key=lambda identity: identity[2])
-                data.update(
-                    {"features": features, "identities": identities, "extensions": extensions}
-                )
-
-        # items
-        if items_requested:
-            try:
-                items = await self.host.bridge.disco_items(
-                    jid,
-                    node=self.args.node,
-                    use_cache=self.args.use_cache,
-                    profile_key=self.host.profile,
-                )
-            except Exception as e:
-                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                items.sort(key=lambda item: item[2])
-                data["items"] = items
-
-        # external
-        if exter_requested:
-            try:
-                ext_services_s = await self.host.bridge.external_disco_get(
-                    jid,
-                    self.host.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("error while doing external service discovery: {e}").format(e=e),
-                    error=True
-                )
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                data["external"] = data_format.deserialise(
-                    ext_services_s, type_check=list
-                )
-
-        # output
-        await self.output(data)
-        self.host.quit()
-
-
-class Version(base.CommandBase):
-    def __init__(self, host):
-        super(Version, self).__init__(host, "version", help=_("software version"))
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", type=str, help=_("Entity to request"))
-
-    async def start(self):
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        try:
-            data = await self.host.bridge.software_version_get(jid, self.host.profile)
-        except Exception as e:
-            self.disp(_("error while trying to get version: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            infos = []
-            name, version, os = data
-            if name:
-                infos.append(_("Software name: {name}").format(name=name))
-            if version:
-                infos.append(_("Software version: {version}").format(version=version))
-            if os:
-                infos.append(_("Operating System: {os}").format(os=os))
-
-            print("\n".join(infos))
-            self.host.quit()
-
-
-class Session(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(Session, self).__init__(
-            host,
-            "session",
-            use_output="dict",
-            extra_outputs=extra_outputs,
-            help=_("running session"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def default_output(self, data):
-        started = data["started"]
-        data["started"] = "{short} (UTC, {relative})".format(
-            short=date_utils.date_fmt(started),
-            relative=date_utils.date_fmt(started, "relative"),
-        )
-        await self.host.output(C.OUTPUT_DICT, "simple", {}, data)
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.session_infos_get(self.host.profile)
-        except Exception as e:
-            self.disp(_("Error getting session infos: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(data)
-            self.host.quit()
-
-
-class Devices(base.CommandBase):
-    def __init__(self, host):
-        super(Devices, self).__init__(
-            host, "devices", use_output=C.OUTPUT_LIST_DICT, help=_("devices of an entity")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid", type=str, nargs="?", default="", help=_("Entity to request")
-        )
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.devices_infos_get(
-                self.args.jid, self.host.profile
-            )
-        except Exception as e:
-            self.disp(_("Error getting devices infos: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            data = data_format.deserialise(data, type_check=list)
-            await self.output(data)
-            self.host.quit()
-
-
-class Info(base.CommandBase):
-    subcommands = (Disco, Version, Session, Devices)
-
-    def __init__(self, host):
-        super(Info, self).__init__(
-            host,
-            "info",
-            use_profile=False,
-            help=_("Get various pieces of information on entities"),
-        )
--- a/libervia/frontends/jp/cmd_input.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,350 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import subprocess
-import argparse
-import sys
-import shlex
-import asyncio
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-
-__commands__ = ["Input"]
-OPT_STDIN = "stdin"
-OPT_SHORT = "short"
-OPT_LONG = "long"
-OPT_POS = "positional"
-OPT_IGNORE = "ignore"
-OPT_TYPES = (OPT_STDIN, OPT_SHORT, OPT_LONG, OPT_POS, OPT_IGNORE)
-OPT_EMPTY_SKIP = "skip"
-OPT_EMPTY_IGNORE = "ignore"
-OPT_EMPTY_CHOICES = (OPT_EMPTY_SKIP, OPT_EMPTY_IGNORE)
-
-
-class InputCommon(base.CommandBase):
-    def __init__(self, host, name, help):
-        base.CommandBase.__init__(
-            self, host, name, use_verbose=True, use_profile=False, help=help
-        )
-        self.idx = 0
-        self.reset()
-
-    def reset(self):
-        self.args_idx = 0
-        self._stdin = []
-        self._opts = []
-        self._pos = []
-        self._values_ori = []
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--encoding", default="utf-8", help=_("encoding of the input data")
-        )
-        self.parser.add_argument(
-            "-i",
-            "--stdin",
-            action="append_const",
-            const=(OPT_STDIN, None),
-            dest="arguments",
-            help=_("standard input"),
-        )
-        self.parser.add_argument(
-            "-s",
-            "--short",
-            type=self.opt(OPT_SHORT),
-            action="append",
-            dest="arguments",
-            help=_("short option"),
-        )
-        self.parser.add_argument(
-            "-l",
-            "--long",
-            type=self.opt(OPT_LONG),
-            action="append",
-            dest="arguments",
-            help=_("long option"),
-        )
-        self.parser.add_argument(
-            "-p",
-            "--positional",
-            type=self.opt(OPT_POS),
-            action="append",
-            dest="arguments",
-            help=_("positional argument"),
-        )
-        self.parser.add_argument(
-            "-x",
-            "--ignore",
-            action="append_const",
-            const=(OPT_IGNORE, None),
-            dest="arguments",
-            help=_("ignore value"),
-        )
-        self.parser.add_argument(
-            "-D",
-            "--debug",
-            action="store_true",
-            help=_("don't actually run commands but echo what would be launched"),
-        )
-        self.parser.add_argument(
-            "--log", type=argparse.FileType("w"), help=_("log stdout to FILE")
-        )
-        self.parser.add_argument(
-            "--log-err", type=argparse.FileType("w"), help=_("log stderr to FILE")
-        )
-        self.parser.add_argument("command", nargs=argparse.REMAINDER)
-
-    def opt(self, type_):
-        return lambda s: (type_, s)
-
-    def add_value(self, value):
-        """add a parsed value according to arguments sequence"""
-        self._values_ori.append(value)
-        arguments = self.args.arguments
-        try:
-            arg_type, arg_name = arguments[self.args_idx]
-        except IndexError:
-            self.disp(
-                _("arguments in input data and in arguments sequence don't match"),
-                error=True,
-            )
-            self.host.quit(C.EXIT_DATA_ERROR)
-        self.args_idx += 1
-        while self.args_idx < len(arguments):
-            next_arg = arguments[self.args_idx]
-            if next_arg[0] not in OPT_TYPES:
-                # value will not be used if False or None, so we skip filter
-                if value not in (False, None):
-                    # we have a filter
-                    filter_type, filter_arg = arguments[self.args_idx]
-                    value = self.filter(filter_type, filter_arg, value)
-            else:
-                break
-            self.args_idx += 1
-
-        if value is None:
-            # we ignore this argument
-            return
-
-        if value is False:
-            # we skip the whole row
-            if self.args.debug:
-                self.disp(
-                    A.color(
-                        C.A_SUBHEADER,
-                        _("values: "),
-                        A.RESET,
-                        ", ".join(self._values_ori),
-                    ),
-                    2,
-                )
-                self.disp(A.color(A.BOLD, _("**SKIPPING**\n")))
-            self.reset()
-            self.idx += 1
-            raise exceptions.CancelError
-
-        if not isinstance(value, list):
-            value = [value]
-
-        for v in value:
-            if arg_type == OPT_STDIN:
-                self._stdin.append(v)
-            elif arg_type == OPT_SHORT:
-                self._opts.append("-{}".format(arg_name))
-                self._opts.append(v)
-            elif arg_type == OPT_LONG:
-                self._opts.append("--{}".format(arg_name))
-                self._opts.append(v)
-            elif arg_type == OPT_POS:
-                self._pos.append(v)
-            elif arg_type == OPT_IGNORE:
-                pass
-            else:
-                self.parser.error(
-                    _(
-                        "Invalid argument, an option type is expected, got {type_}:{name}"
-                    ).format(type_=arg_type, name=arg_name)
-                )
-
-    async def runCommand(self):
-        """run requested command with parsed arguments"""
-        if self.args_idx != len(self.args.arguments):
-            self.disp(
-                _("arguments in input data and in arguments sequence don't match"),
-                error=True,
-            )
-            self.host.quit(C.EXIT_DATA_ERROR)
-        end = '\n' if self.args.debug else ' '
-        self.disp(
-            A.color(C.A_HEADER, _("command {idx}").format(idx=self.idx)),
-            end = end,
-        )
-        stdin = "".join(self._stdin)
-        if self.args.debug:
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("values: "),
-                    A.RESET,
-                    ", ".join([shlex.quote(a) for a in self._values_ori])
-                ),
-                2,
-            )
-
-            if stdin:
-                self.disp(A.color(C.A_SUBHEADER, "--- STDIN ---"))
-                self.disp(stdin)
-                self.disp(A.color(C.A_SUBHEADER, "-------------"))
-
-            self.disp(
-                "{indent}{prog} {static} {options} {positionals}".format(
-                    indent=4 * " ",
-                    prog=sys.argv[0],
-                    static=" ".join(self.args.command),
-                    options=" ".join(shlex.quote(o) for o in self._opts),
-                    positionals=" ".join(shlex.quote(p) for p in self._pos),
-                )
-            )
-            self.disp("\n")
-        else:
-            self.disp(" (" + ", ".join(self._values_ori) + ")", 2, end=' ')
-            args = [sys.argv[0]] + self.args.command + self._opts + self._pos
-            p = await asyncio.create_subprocess_exec(
-                *args,
-                stdin=subprocess.PIPE,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-            )
-            stdout, stderr = await p.communicate(stdin.encode('utf-8'))
-            log = self.args.log
-            log_err = self.args.log_err
-            log_tpl = "{command}\n{buff}\n\n"
-            if log:
-                log.write(log_tpl.format(
-                    command=" ".join(shlex.quote(a) for a in args),
-                    buff=stdout.decode('utf-8', 'replace')))
-            if log_err:
-                log_err.write(log_tpl.format(
-                    command=" ".join(shlex.quote(a) for a in args),
-                    buff=stderr.decode('utf-8', 'replace')))
-            ret = p.returncode
-            if ret == 0:
-                self.disp(A.color(C.A_SUCCESS, _("OK")))
-            else:
-                self.disp(A.color(C.A_FAILURE, _("FAILED")))
-
-        self.reset()
-        self.idx += 1
-
-    def filter(self, filter_type, filter_arg, value):
-        """change input value
-
-        @param filter_type(unicode): name of the filter
-        @param filter_arg(unicode, None): argument of the filter
-        @param value(unicode): value to filter
-        @return (unicode, False, None): modified value
-            False to skip the whole row
-            None to ignore this argument (but continue row with other ones)
-        """
-        raise NotImplementedError
-
-
-class Csv(InputCommon):
-    def __init__(self, host):
-        super(Csv, self).__init__(host, "csv", _("comma-separated values"))
-
-    def add_parser_options(self):
-        InputCommon.add_parser_options(self)
-        self.parser.add_argument(
-            "-r",
-            "--row",
-            type=int,
-            default=0,
-            help=_("starting row (previous ones will be ignored)"),
-        )
-        self.parser.add_argument(
-            "-S",
-            "--split",
-            action="append_const",
-            const=("split", None),
-            dest="arguments",
-            help=_("split value in several options"),
-        )
-        self.parser.add_argument(
-            "-E",
-            "--empty",
-            action="append",
-            type=self.opt("empty"),
-            dest="arguments",
-            help=_("action to do on empty value ({choices})").format(
-                choices=", ".join(OPT_EMPTY_CHOICES)
-            ),
-        )
-
-    def filter(self, filter_type, filter_arg, value):
-        if filter_type == "split":
-            return value.split()
-        elif filter_type == "empty":
-            if filter_arg == OPT_EMPTY_IGNORE:
-                return value if value else None
-            elif filter_arg == OPT_EMPTY_SKIP:
-                return value if value else False
-            else:
-                self.parser.error(
-                    _("--empty value must be one of {choices}").format(
-                        choices=", ".join(OPT_EMPTY_CHOICES)
-                    )
-                )
-
-        super(Csv, self).filter(filter_type, filter_arg, value)
-
-    async def start(self):
-        import csv
-
-        if self.args.encoding:
-            sys.stdin.reconfigure(encoding=self.args.encoding, errors="replace")
-        reader = csv.reader(sys.stdin)
-        for idx, row in enumerate(reader):
-            try:
-                if idx < self.args.row:
-                    continue
-                for value in row:
-                    self.add_value(value)
-                await self.runCommand()
-            except exceptions.CancelError:
-                #  this row has been cancelled, we skip it
-                continue
-
-        self.host.quit()
-
-
-class Input(base.CommandBase):
-    subcommands = (Csv,)
-
-    def __init__(self, host):
-        super(Input, self).__init__(
-            host,
-            "input",
-            use_profile=False,
-            help=_("launch command with external input"),
-        )
--- a/libervia/frontends/jp/cmd_invitation.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,371 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import data_format
-
-__commands__ = ["Invitation"]
-
-
-class Create(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "create",
-            use_profile=False,
-            use_output=C.OUTPUT_DICT,
-            help=_("create and send an invitation"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            default="",
-            help="jid of the invitee (default: generate one)",
-        )
-        self.parser.add_argument(
-            "-P",
-            "--password",
-            default="",
-            help="password of the invitee profile/XMPP account (default: generate one)",
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help="name of the invitee",
-        )
-        self.parser.add_argument(
-            "-N",
-            "--host-name",
-            default="",
-            help="name of the host",
-        )
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            action="append",
-            default=[],
-            help="email(s) to send the invitation to (if --no-email is set, email will just be saved)",
-        )
-        self.parser.add_argument(
-            "--no-email", action="store_true", help="do NOT send invitation email"
-        )
-        self.parser.add_argument(
-            "-l",
-            "--lang",
-            default="",
-            help="main language spoken by the invitee",
-        )
-        self.parser.add_argument(
-            "-u",
-            "--url",
-            default="",
-            help="template to construct the URL",
-        )
-        self.parser.add_argument(
-            "-s",
-            "--subject",
-            default="",
-            help="subject of the invitation email (default: generic subject)",
-        )
-        self.parser.add_argument(
-            "-b",
-            "--body",
-            default="",
-            help="body of the invitation email (default: generic body)",
-        )
-        self.parser.add_argument(
-            "-x",
-            "--extra",
-            metavar=("KEY", "VALUE"),
-            action="append",
-            nargs=2,
-            default=[],
-            help="extra data to associate with invitation/invitee",
-        )
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default="",
-            help="profile doing the invitation (default: don't associate profile)",
-        )
-
-    async def start(self):
-        extra = dict(self.args.extra)
-        email = self.args.email[0] if self.args.email else None
-        emails_extra = self.args.email[1:]
-        if self.args.no_email:
-            if email:
-                extra["email"] = email
-                data_format.iter2dict("emails_extra", emails_extra)
-        else:
-            if not email:
-                self.parser.error(
-                    _("you need to specify an email address to send email invitation")
-                )
-
-        try:
-            invitation_data = await self.host.bridge.invitation_create(
-                email,
-                emails_extra,
-                self.args.jid,
-                self.args.password,
-                self.args.name,
-                self.args.host_name,
-                self.args.lang,
-                self.args.url,
-                self.args.subject,
-                self.args.body,
-                extra,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(invitation_data)
-            self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_profile=False,
-            use_output=C.OUTPUT_DICT,
-            help=_("get invitation data"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("id", help=_("invitation UUID"))
-        self.parser.add_argument(
-            "-j",
-            "--with-jid",
-            action="store_true",
-            help=_("start profile session and retrieve jid"),
-        )
-
-    async def output_data(self, data, jid_=None):
-        if jid_ is not None:
-            data["jid"] = jid_
-        await self.output(data)
-        self.host.quit()
-
-    async def start(self):
-        try:
-            invitation_data = await self.host.bridge.invitation_get(
-                self.args.id,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't get invitation data: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if not self.args.with_jid:
-            await self.output_data(invitation_data)
-        else:
-            profile = invitation_data["guest_profile"]
-            try:
-                await self.host.bridge.profile_start_session(
-                    invitation_data["password"],
-                    profile,
-                )
-            except Exception as e:
-                self.disp(msg=_("can't start session: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            try:
-                jid_ = await self.host.bridge.param_get_a_async(
-                    "JabberID",
-                    "Connection",
-                    profile_key=profile,
-                )
-            except Exception as e:
-                self.disp(msg=_("can't retrieve jid: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            await self.output_data(invitation_data, jid_)
-
-
-class Delete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_profile=False,
-            help=_("delete guest account"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("id", help=_("invitation UUID"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.invitation_delete(
-                self.args.id,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't delete guest account: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class Modify(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "modify", use_profile=False, help=_("modify existing invitation")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--replace", action="store_true", help="replace the whole data"
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help="name of the invitee",
-        )
-        self.parser.add_argument(
-            "-N",
-            "--host-name",
-            default="",
-            help="name of the host",
-        )
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            default="",
-            help="email to send the invitation to (if --no-email is set, email will just be saved)",
-        )
-        self.parser.add_argument(
-            "-l",
-            "--lang",
-            dest="language",
-            default="",
-            help="main language spoken by the invitee",
-        )
-        self.parser.add_argument(
-            "-x",
-            "--extra",
-            metavar=("KEY", "VALUE"),
-            action="append",
-            nargs=2,
-            default=[],
-            help="extra data to associate with invitation/invitee",
-        )
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default="",
-            help="profile doing the invitation (default: don't associate profile",
-        )
-        self.parser.add_argument("id", help=_("invitation UUID"))
-
-    async def start(self):
-        extra = dict(self.args.extra)
-        for arg_name in ("name", "host_name", "email", "language", "profile"):
-            value = getattr(self.args, arg_name)
-            if not value:
-                continue
-            if arg_name in extra:
-                self.parser.error(
-                    _(
-                        "you can't set {arg_name} in both optional argument and extra"
-                    ).format(arg_name=arg_name)
-                )
-            extra[arg_name] = value
-        try:
-            await self.host.bridge.invitation_modify(
-                self.args.id,
-                extra,
-                self.args.replace,
-            )
-        except Exception as e:
-            self.disp(f"can't modify invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("invitations have been modified successfuly"))
-            self.host.quit(C.EXIT_OK)
-
-
-class List(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "list",
-            use_profile=False,
-            use_output=C.OUTPUT_COMPLEX,
-            extra_outputs=extra_outputs,
-            help=_("list invitations data"),
-        )
-
-    def default_output(self, data):
-        for idx, datum in enumerate(data.items()):
-            if idx:
-                self.disp("\n")
-            key, invitation_data = datum
-            self.disp(A.color(C.A_HEADER, key))
-            indent = "  "
-            for k, v in invitation_data.items():
-                self.disp(indent + A.color(C.A_SUBHEADER, k + ":") + " " + str(v))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default=C.PROF_KEY_NONE,
-            help=_("return only invitations linked to this profile"),
-        )
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.invitation_list(
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"return only invitations linked to this profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(data)
-            self.host.quit()
-
-
-class Invitation(base.CommandBase):
-    subcommands = (Create, Get, Delete, Modify, List)
-
-    def __init__(self, host):
-        super(Invitation, self).__init__(
-            host,
-            "invitation",
-            use_profile=False,
-            help=_("invitation of user(s) without XMPP account"),
-        )
--- a/libervia/frontends/jp/cmd_list.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,351 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import json
-import os
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp import common
-from libervia.frontends.jp.constants import Const as C
-from . import base
-
-__commands__ = ["List"]
-
-FIELDS_MAP = "mapping"
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS},
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            use_output=C.OUTPUT_LIST_XMLUI,
-            help=_("get lists"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
-        try:
-            lists_data = data_format.deserialise(
-                await self.host.bridge.list_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    "",
-                    self.get_pubsub_extra(),
-                    self.profile,
-                ),
-                type_check=list,
-            )
-        except Exception as e:
-            self.disp(f"can't get lists: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(lists_data[0])
-            self.host.quit(C.EXIT_OK)
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("set a list item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs="+",
-            dest="fields",
-            required=True,
-            metavar=("NAME", "VALUES"),
-            help=_("field(s) to set (required)"),
-        )
-        self.parser.add_argument(
-            "-U",
-            "--update",
-            choices=("auto", "true", "false"),
-            default="auto",
-            help=_("update existing item instead of replacing it (DEFAULT: auto)"),
-        )
-        self.parser.add_argument(
-            "item",
-            nargs="?",
-            default="",
-            help=_("id, URL of the item to update, or nothing for new item"),
-        )
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
-        if self.args.update == "auto":
-            # we update if we have a item id specified
-            update = bool(self.args.item)
-        else:
-            update = C.bool(self.args.update)
-
-        values = {}
-
-        for field_data in self.args.fields:
-            values.setdefault(field_data[0], []).extend(field_data[1:])
-
-        extra = {"update": update}
-
-        try:
-            item_id = await self.host.bridge.list_set(
-                self.args.service,
-                self.args.node,
-                values,
-                "",
-                self.args.item,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set list item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(f"item {str(item_id or self.args.item)!r} set successfully")
-            self.host.quit(C.EXIT_OK)
-
-
-class Delete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("delete a list item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true", help=_("delete without confirmation")
-        )
-        self.parser.add_argument(
-            "-N", "--notify", action="store_true", help=_("notify deletion")
-        )
-        self.parser.add_argument(
-            "item",
-            help=_("id of the item to delete"),
-        )
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
-        if not self.args.item:
-            self.parser.error(_("You need to specify a list item to delete"))
-        if not self.args.force:
-            message = _("Are you sure to delete list item {item_id} ?").format(
-                item_id=self.args.item
-            )
-            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
-        try:
-            await self.host.bridge.list_delete_item(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.notify,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't delete item: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("item {item} has been deleted").format(item=self.args.item))
-            self.host.quit(C.EXIT_OK)
-
-
-class Import(base.CommandBase):
-    # TODO: factorize with blog/import
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "import",
-            use_progress=True,
-            use_verbose=True,
-            help=_("import tickets from external software/dataset"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "importer",
-            nargs="?",
-            help=_("importer name, nothing to display importers list"),
-        )
-        self.parser.add_argument(
-            "-o",
-            "--option",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar=("NAME", "VALUE"),
-            help=_("importer specific options (see importer description)"),
-        )
-        self.parser.add_argument(
-            "-m",
-            "--map",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar=("IMPORTED_FIELD", "DEST_FIELD"),
-            help=_(
-                "specified field in import data will be put in dest field (default: use "
-                "same field name, or ignore if it doesn't exist)"
-            ),
-        )
-        self.parser.add_argument(
-            "-s",
-            "--service",
-            default="",
-            metavar="PUBSUB_SERVICE",
-            help=_("PubSub service where the items must be uploaded (default: server)"),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--node",
-            default="",
-            metavar="PUBSUB_NODE",
-            help=_(
-                "PubSub node where the items must be uploaded (default: tickets' "
-                "defaults)"
-            ),
-        )
-        self.parser.add_argument(
-            "location",
-            nargs="?",
-            help=_(
-                "importer data location (see importer description), nothing to show "
-                "importer description"
-            ),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("Tickets upload started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("Tickets uploaded successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        self.disp(
-            _("Error while uploading tickets: {error_msg}").format(error_msg=error_msg),
-            error=True,
-        )
-
-    async def start(self):
-        if self.args.location is None:
-            # no location, the list of importer or description is requested
-            for name in ("option", "service", "node"):
-                if getattr(self.args, name):
-                    self.parser.error(
-                        _(
-                            "{name} argument can't be used without location argument"
-                        ).format(name=name)
-                    )
-            if self.args.importer is None:
-                self.disp(
-                    "\n".join(
-                        [
-                            f"{name}: {desc}"
-                            for name, desc in await self.host.bridge.ticketsImportList()
-                        ]
-                    )
-                )
-            else:
-                try:
-                    short_desc, long_desc = await self.host.bridge.ticketsImportDesc(
-                        self.args.importer
-                    )
-                except Exception as e:
-                    self.disp(f"can't get importer description: {e}", error=True)
-                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                else:
-                    self.disp(f"{name}: {short_desc}\n\n{long_desc}")
-            self.host.quit()
-        else:
-            # we have a location, an import is requested
-
-            if self.args.progress:
-                # we use a custom progress bar template as we want a counter
-                self.pbar_template = [
-                    _("Progress: "),
-                    ["Percentage"],
-                    " ",
-                    ["Bar"],
-                    " ",
-                    ["Counter"],
-                    " ",
-                    ["ETA"],
-                ]
-
-            options = {key: value for key, value in self.args.option}
-            fields_map = dict(self.args.map)
-            if fields_map:
-                if FIELDS_MAP in options:
-                    self.parser.error(
-                        _(
-                            "fields_map must be specified either preencoded in --option or "
-                            "using --map, but not both at the same time"
-                        )
-                    )
-                options[FIELDS_MAP] = json.dumps(fields_map)
-
-            try:
-                progress_id = await self.host.bridge.ticketsImport(
-                    self.args.importer,
-                    self.args.location,
-                    options,
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("Error while trying to import tickets: {e}").format(e=e),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                await self.set_progress_id(progress_id)
-
-
-class List(base.CommandBase):
-    subcommands = (Get, Set, Delete, Import)
-
-    def __init__(self, host):
-        super(List, self).__init__(
-            host, "list", use_profile=False, help=_("pubsub lists handling")
-        )
--- a/libervia/frontends/jp/cmd_merge_request.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,210 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import os.path
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import xmlui_manager
-from libervia.frontends.jp import common
-
-__commands__ = ["MergeRequest"]
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("publish or update a merge request"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-i",
-            "--item",
-            default="",
-            help=_("id or URL of the request to update, or nothing for a new one"),
-        )
-        self.parser.add_argument(
-            "-r",
-            "--repository",
-            metavar="PATH",
-            default=".",
-            help=_("path of the repository (DEFAULT: current directory)"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("publish merge request without confirmation"),
-        )
-        self.parser.add_argument(
-            "-l",
-            "--label",
-            dest="labels",
-            action="append",
-            help=_("labels to categorize your request"),
-        )
-
-    async def start(self):
-        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
-        await common.fill_well_known_uri(self, self.repository, "merge requests")
-        if not self.args.force:
-            message = _(
-                "You are going to publish your changes to service "
-                "[{service}], are you sure ?"
-            ).format(service=self.args.service)
-            await self.host.confirm_or_quit(
-                message, _("merge request publication cancelled")
-            )
-
-        extra = {"update": True} if self.args.item else {}
-        values = {}
-        if self.args.labels is not None:
-            values["labels"] = self.args.labels
-        try:
-            published_id = await self.host.bridge.merge_request_set(
-                self.args.service,
-                self.args.node,
-                self.repository,
-                "auto",
-                values,
-                "",
-                self.args.item,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create merge requests: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if published_id:
-            self.disp(
-                _("Merge request published at {published_id}").format(
-                    published_id=published_id
-                )
-            )
-        else:
-            self.disp(_("Merge request published"))
-
-        self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS},
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("get a merge request"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "merge requests", meta_map={})
-        extra = {}
-        try:
-            requests_data = data_format.deserialise(
-                await self.host.bridge.merge_requests_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    "",
-                    data_format.serialise(extra),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't get merge request: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if self.verbosity >= 1:
-            whitelist = None
-        else:
-            whitelist = {"id", "title", "body"}
-        for request_xmlui in requests_data["items"]:
-            xmlui = xmlui_manager.create(self.host, request_xmlui, whitelist=whitelist)
-            await xmlui.show(values_only=True)
-            self.disp("")
-        self.host.quit(C.EXIT_OK)
-
-
-class Import(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "import",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM, C.ITEM},
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("import a merge request"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-r",
-            "--repository",
-            metavar="PATH",
-            default=".",
-            help=_("path of the repository (DEFAULT: current directory)"),
-        )
-
-    async def start(self):
-        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
-        await common.fill_well_known_uri(
-            self, self.repository, "merge requests", meta_map={}
-        )
-        extra = {}
-        try:
-            await self.host.bridge.merge_requests_import(
-                self.repository,
-                self.args.item,
-                self.args.service,
-                self.args.node,
-                extra,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't import merge request: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class MergeRequest(base.CommandBase):
-    subcommands = (Set, Get, Import)
-
-    def __init__(self, host):
-        super(MergeRequest, self).__init__(
-            host, "merge-request", use_profile=False, help=_("merge-request management")
-        )
--- a/libervia/frontends/jp/cmd_message.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,327 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from pathlib import Path
-import sys
-
-from twisted.python import filepath
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.utils import clean_ustr
-from libervia.frontends.jp import base
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.tools import jid
-
-
-__commands__ = ["Message"]
-
-
-class Send(base.CommandBase):
-    def __init__(self, host):
-        super(Send, self).__init__(host, "send", help=_("send a message to a contact"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-l", "--lang", type=str, default="", help=_("language of the message")
-        )
-        self.parser.add_argument(
-            "-s",
-            "--separate",
-            action="store_true",
-            help=_(
-                "separate xmpp messages: send one message per line instead of one "
-                "message alone."
-            ),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--new-line",
-            action="store_true",
-            help=_(
-                "add a new line at the beginning of the input"
-            ),
-        )
-        self.parser.add_argument(
-            "-S",
-            "--subject",
-            help=_("subject of the message"),
-        )
-        self.parser.add_argument(
-            "-L", "--subject-lang", type=str, default="", help=_("language of subject")
-        )
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,),
-            default=C.MESS_TYPE_AUTO,
-            help=_("type of the message"),
-        )
-        self.parser.add_argument("-e", "--encrypt", metavar="ALGORITHM",
-                                 help=_("encrypt message using given algorithm"))
-        self.parser.add_argument(
-            "--encrypt-noreplace",
-            action="store_true",
-            help=_("don't replace encryption algorithm if an other one is already used"))
-        self.parser.add_argument(
-            "-a", "--attach", dest="attachments", action="append", metavar="FILE_PATH",
-            help=_("add a file as an attachment")
-        )
-        syntax = self.parser.add_mutually_exclusive_group()
-        syntax.add_argument("-x", "--xhtml", action="store_true", help=_("XHTML body"))
-        syntax.add_argument("-r", "--rich", action="store_true", help=_("rich body"))
-        self.parser.add_argument(
-            "jid", help=_("the destination jid")
-        )
-
-    async def send_stdin(self, dest_jid):
-        """Send incomming data on stdin to jabber contact
-
-        @param dest_jid: destination jid
-        """
-        header = "\n" if self.args.new_line else ""
-        # FIXME: stdin is not read asynchronously at the moment
-        stdin_lines = [
-            stream for stream in sys.stdin.readlines()
-        ]
-        extra = {}
-        if self.args.subject is None:
-            subject = {}
-        else:
-            subject = {self.args.subject_lang: self.args.subject}
-
-        if self.args.xhtml or self.args.rich:
-            key = "xhtml" if self.args.xhtml else "rich"
-            if self.args.lang:
-                key = f"{key}_{self.args.lang}"
-            extra[key] = clean_ustr("".join(stdin_lines))
-            stdin_lines = []
-
-        to_send = []
-
-        error = False
-
-        if self.args.separate:
-            # we send stdin in several messages
-            if header:
-                # first we sent the header
-                try:
-                    await self.host.bridge.message_send(
-                        dest_jid,
-                        {self.args.lang: header},
-                        subject,
-                        self.args.type,
-                        profile_key=self.profile,
-                    )
-                except Exception as e:
-                    self.disp(f"can't send header: {e}", error=True)
-                    error = True
-
-            to_send.extend({self.args.lang: clean_ustr(l.replace("\n", ""))}
-                           for l in stdin_lines)
-        else:
-            # we sent all in a single message
-            if not (self.args.xhtml or self.args.rich):
-                msg = {self.args.lang: header + clean_ustr("".join(stdin_lines))}
-            else:
-                msg = {}
-            to_send.append(msg)
-
-        if self.args.attachments:
-            attachments = extra[C.KEY_ATTACHMENTS] = []
-            for attachment in self.args.attachments:
-                try:
-                    file_path = str(Path(attachment).resolve(strict=True))
-                except FileNotFoundError:
-                    self.disp("file {attachment} doesn't exists, ignoring", error=True)
-                else:
-                    attachments.append({"path": file_path})
-
-        for idx, msg in enumerate(to_send):
-            if idx > 0 and C.KEY_ATTACHMENTS in extra:
-                # if we send several messages, we only want to send attachments with the
-                # first one
-                del extra[C.KEY_ATTACHMENTS]
-            try:
-                await self.host.bridge.message_send(
-                    dest_jid,
-                    msg,
-                    subject,
-                    self.args.type,
-                    data_format.serialise(extra),
-                    profile_key=self.host.profile)
-            except Exception as e:
-                self.disp(f"can't send message {msg!r}: {e}", error=True)
-                error = True
-
-        if error:
-            # at least one message sending failed
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-    async def start(self):
-        if self.args.xhtml and self.args.separate:
-            self.disp(
-                "argument -s/--separate is not compatible yet with argument -x/--xhtml",
-                error=True,
-            )
-            self.host.quit(C.EXIT_BAD_ARG)
-
-        jids = await self.host.check_jids([self.args.jid])
-        jid_ = jids[0]
-
-        if self.args.encrypt_noreplace and self.args.encrypt is None:
-            self.parser.error("You need to use --encrypt if you use --encrypt-noreplace")
-
-        if self.args.encrypt is not None:
-            try:
-                namespace = await self.host.bridge.encryption_namespace_get(
-                    self.args.encrypt)
-            except Exception as e:
-                self.disp(f"can't get encryption namespace: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            try:
-                await self.host.bridge.message_encryption_start(
-                    jid_, namespace, not self.args.encrypt_noreplace, self.profile
-                )
-            except Exception as e:
-                self.disp(f"can't start encryption session: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        await self.send_stdin(jid_)
-
-
-class Retract(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(host, "retract", help=_("retract a message"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "message_id",
-            help=_("ID of the message (internal ID)")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.message_retract(
-                self.args.message_id,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't retract message: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                "message retraction has been requested, please note that this is a "
-                "request which can't be enforced (see documentation for details).")
-            self.host.quit(C.EXIT_OK)
-
-
-class MAM(base.CommandBase):
-
-    def __init__(self, host):
-        super(MAM, self).__init__(
-            host, "mam", use_output=C.OUTPUT_MESS, use_verbose=True,
-            help=_("query archives using MAM"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-s", "--service", default="",
-            help=_("jid of the service (default: profile's server"))
-        self.parser.add_argument(
-            "-S", "--start", dest="mam_start", type=base.date_decoder,
-            help=_(
-                "start fetching archive from this date (default: from the beginning)"))
-        self.parser.add_argument(
-            "-E", "--end", dest="mam_end", type=base.date_decoder,
-            help=_("end fetching archive after this date (default: no limit)"))
-        self.parser.add_argument(
-            "-W", "--with", dest="mam_with",
-            help=_("retrieve only archives with this jid"))
-        self.parser.add_argument(
-            "-m", "--max", dest="rsm_max", type=int, default=20,
-            help=_("maximum number of items to retrieve, using RSM (default: 20))"))
-        rsm_page_group = self.parser.add_mutually_exclusive_group()
-        rsm_page_group.add_argument(
-            "-a", "--after", dest="rsm_after",
-            help=_("find page after this item"), metavar='ITEM_ID')
-        rsm_page_group.add_argument(
-            "-b", "--before", dest="rsm_before",
-            help=_("find page before this item"), metavar='ITEM_ID')
-        rsm_page_group.add_argument(
-            "--index", dest="rsm_index", type=int,
-            help=_("index of the page to retrieve"))
-
-    async def start(self):
-        extra = {}
-        if self.args.mam_start is not None:
-            extra["mam_start"] = float(self.args.mam_start)
-        if self.args.mam_end is not None:
-            extra["mam_end"] = float(self.args.mam_end)
-        if self.args.mam_with is not None:
-            extra["mam_with"] = self.args.mam_with
-        for suff in ('max', 'after', 'before', 'index'):
-            key = 'rsm_' + suff
-            value = getattr(self.args,key)
-            if value is not None:
-                extra[key] = str(value)
-        try:
-            data, metadata_s, profile = await self.host.bridge.mam_get(
-                self.args.service, data_format.serialise(extra), self.profile)
-        except Exception as e:
-            self.disp(f"can't retrieve MAM archives: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        metadata = data_format.deserialise(metadata_s)
-
-        try:
-            session_info = await self.host.bridge.session_infos_get(self.profile)
-        except Exception as e:
-            self.disp(f"can't get session infos: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        # we need to fill own_jid for message output
-        self.host.own_jid = jid.JID(session_info["jid"])
-
-        await self.output(data)
-
-        # FIXME: metadata are not displayed correctly and don't play nice with output
-        #        they should be added to output data somehow
-        if self.verbosity:
-            for value in ("rsm_first", "rsm_last", "rsm_index", "rsm_count",
-                          "mam_complete", "mam_stable"):
-                if value in metadata:
-                    label = value.split("_")[1]
-                    self.disp(A.color(
-                        C.A_HEADER, label, ': ' , A.RESET, metadata[value]))
-
-        self.host.quit()
-
-
-class Message(base.CommandBase):
-    subcommands = (Send, Retract, MAM)
-
-    def __init__(self, host):
-        super(Message, self).__init__(
-            host, "message", use_profile=False, help=_("messages handling")
-        )
--- a/libervia/frontends/jp/cmd_param.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,183 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from libervia.backend.core.i18n import _
-from .constants import Const as C
-
-__commands__ = ["Param"]
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        super(Get, self).__init__(
-            host, "get", need_connect=False, help=_("get a parameter value")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "category", nargs="?", help=_("category of the parameter")
-        )
-        self.parser.add_argument("name", nargs="?", help=_("name of the parameter"))
-        self.parser.add_argument(
-            "-a",
-            "--attribute",
-            type=str,
-            default="value",
-            help=_("name of the attribute to get"),
-        )
-        self.parser.add_argument(
-            "--security-limit", type=int, default=-1, help=_("security limit")
-        )
-
-    async def start(self):
-        if self.args.category is None:
-            categories = await self.host.bridge.params_categories_get()
-            print("\n".join(categories))
-        elif self.args.name is None:
-            try:
-                values_dict = await self.host.bridge.params_values_from_category_get_async(
-                    self.args.category, self.args.security_limit, "", "", self.profile
-                )
-            except Exception as e:
-                self.disp(
-                    _("can't find requested parameters: {e}").format(e=e), error=True
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                for name, value in values_dict.items():
-                    print(f"{name}\t{value}")
-        else:
-            try:
-                value = await self.host.bridge.param_get_a_async(
-                    self.args.name,
-                    self.args.category,
-                    self.args.attribute,
-                    self.args.security_limit,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("can't find requested parameter: {e}").format(e=e), error=True
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                print(value)
-        self.host.quit()
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        super(Set, self).__init__(
-            host, "set", need_connect=False, help=_("set a parameter value")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("category", help=_("category of the parameter"))
-        self.parser.add_argument("name", help=_("name of the parameter"))
-        self.parser.add_argument("value", help=_("name of the parameter"))
-        self.parser.add_argument(
-            "--security-limit", type=int, default=-1, help=_("security limit")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.param_set(
-                self.args.name,
-                self.args.value,
-                self.args.category,
-                self.args.security_limit,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't set requested parameter: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class SaveTemplate(base.CommandBase):
-    # FIXME: this should probably be removed, it's not used and not useful for end-user
-
-    def __init__(self, host):
-        super(SaveTemplate, self).__init__(
-            host,
-            "save",
-            use_profile=False,
-            help=_("save parameters template to xml file"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("filename", type=str, help=_("output file"))
-
-    async def start(self):
-        """Save parameters template to XML file"""
-        try:
-            await self.host.bridge.params_template_save(self.args.filename)
-        except Exception as e:
-            self.disp(_("can't save parameters to file: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _("parameters saved to file {filename}").format(
-                    filename=self.args.filename
-                )
-            )
-            self.host.quit()
-
-
-class LoadTemplate(base.CommandBase):
-    # FIXME: this should probably be removed, it's not used and not useful for end-user
-
-    def __init__(self, host):
-        super(LoadTemplate, self).__init__(
-            host,
-            "load",
-            use_profile=False,
-            help=_("load parameters template from xml file"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("filename", type=str, help=_("input file"))
-
-    async def start(self):
-        """Load parameters template from xml file"""
-        try:
-            self.host.bridge.params_template_load(self.args.filename)
-        except Exception as e:
-            self.disp(_("can't load parameters from file: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _("parameters loaded from file {filename}").format(
-                    filename=self.args.filename
-                )
-            )
-            self.host.quit()
-
-
-class Param(base.CommandBase):
-    subcommands = (Get, Set, SaveTemplate, LoadTemplate)
-
-    def __init__(self, host):
-        super(Param, self).__init__(
-            host, "param", use_profile=False, help=_("Save/load parameters template")
-        )
--- a/libervia/frontends/jp/cmd_ping.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-
-__commands__ = ["Ping"]
-
-
-class Ping(base.CommandBase):
-    def __init__(self, host):
-        super(Ping, self).__init__(host, "ping", help=_("ping XMPP entity"))
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", help=_("jid to ping"))
-        self.parser.add_argument(
-            "-d", "--delay-only", action="store_true", help=_("output delay only (in s)")
-        )
-
-    async def start(self):
-        try:
-            pong_time = await self.host.bridge.ping(self.args.jid, self.profile)
-        except Exception as e:
-            self.disp(msg=_("can't do the ping: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            msg = pong_time if self.args.delay_only else f"PONG ({pong_time} s)"
-            self.disp(msg)
-            self.host.quit()
--- a/libervia/frontends/jp/cmd_pipe.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,163 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import asyncio
-import errno
-from functools import partial
-import socket
-import sys
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.frontends.jp import base
-from libervia.frontends.jp import xmlui_manager
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.tools import jid
-
-__commands__ = ["Pipe"]
-
-START_PORT = 9999
-
-
-class PipeOut(base.CommandBase):
-    def __init__(self, host):
-        super(PipeOut, self).__init__(host, "out", help=_("send a pipe a stream"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid", help=_("the destination jid")
-        )
-
-    async def start(self):
-        """ Create named pipe, and send stdin to it """
-        try:
-            port = await self.host.bridge.stream_out(
-                await self.host.get_full_jid(self.args.jid),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't start stream: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            # FIXME: we use temporarily blocking code here, as it simplify
-            #        asyncio port: "loop.connect_read_pipe(lambda: reader_protocol,
-            #        sys.stdin.buffer)" doesn't work properly when a file is piped in
-            #        (we get a "ValueError: Pipe transport is for pipes/sockets only.")
-            #        while it's working well for simple text sending.
-
-            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            s.connect(("127.0.0.1", int(port)))
-
-            while True:
-                buf = sys.stdin.buffer.read(4096)
-                if not buf:
-                    break
-                try:
-                    s.sendall(buf)
-                except socket.error as e:
-                    if e.errno == errno.EPIPE:
-                        sys.stderr.write(f"e\n")
-                        self.host.quit(1)
-                    else:
-                        raise e
-            self.host.quit()
-
-
-async def handle_stream_in(reader, writer, host):
-    """Write all received data to stdout"""
-    while True:
-        data = await reader.read(4096)
-        if not data:
-            break
-        sys.stdout.buffer.write(data)
-        try:
-            sys.stdout.flush()
-        except IOError as e:
-            sys.stderr.write(f"{e}\n")
-            break
-    host.quit_from_signal()
-
-
-class PipeIn(base.CommandAnswering):
-    def __init__(self, host):
-        super(PipeIn, self).__init__(host, "in", help=_("receive a pipe stream"))
-        self.action_callbacks = {"STREAM": self.on_stream_action}
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jids",
-            nargs="*",
-            help=_('Jids accepted (none means "accept everything")'),
-        )
-
-    def get_xmlui_id(self, action_data):
-        try:
-            xml_ui = action_data["xmlui"]
-        except KeyError:
-            self.disp(_("Action has no XMLUI"), 1)
-        else:
-            ui = xmlui_manager.create(self.host, xml_ui)
-            if not ui.submit_id:
-                self.disp(_("Invalid XMLUI received"), error=True)
-                self.quit_from_signal(C.EXIT_INTERNAL_ERROR)
-            return ui.submit_id
-
-    async def on_stream_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            self.host.quit_from_signal(C.EXIT_ERROR)
-        try:
-            from_jid = jid.JID(action_data["from_jid"])
-        except KeyError:
-            self.disp(_("Ignoring action without from_jid data"), error=True)
-            return
-
-        if not self.bare_jids or from_jid.bare in self.bare_jids:
-            host, port = "localhost", START_PORT
-            while True:
-                try:
-                    server = await asyncio.start_server(
-                        partial(handle_stream_in, host=self.host), host, port)
-                except socket.error as e:
-                    if e.errno == errno.EADDRINUSE:
-                        port += 1
-                    else:
-                        raise e
-                else:
-                    break
-            xmlui_data = {"answer": C.BOOL_TRUE, "port": str(port)}
-            await self.host.bridge.action_launch(
-                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-            )
-            async with server:
-                await server.serve_forever()
-            self.host.quit_from_signal()
-
-    async def start(self):
-        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
-        await self.start_answering()
-
-
-class Pipe(base.CommandBase):
-    subcommands = (PipeOut, PipeIn)
-
-    def __init__(self, host):
-        super(Pipe, self).__init__(
-            host, "pipe", use_profile=False, help=_("stream piping through XMPP")
-        )
--- a/libervia/frontends/jp/cmd_profile.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,280 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""This module permits to manage profiles. It can list, create, delete
-and retrieve information about a profile."""
-
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.core.log import getLogger
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp import base
-
-log = getLogger(__name__)
-
-
-__commands__ = ["Profile"]
-
-PROFILE_HELP = _('The name of the profile')
-
-
-class ProfileConnect(base.CommandBase):
-    """Dummy command to use profile_session parent, i.e. to be able to connect without doing anything else"""
-
-    def __init__(self, host):
-        # it's weird to have a command named "connect" with need_connect=False, but it can be handy to be able
-        # to launch just the session, so some paradoxes don't hurt
-        super(ProfileConnect, self).__init__(host, 'connect', need_connect=False, help=('connect a profile'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        # connection is already managed by profile common commands
-        # so we just need to check arguments and quit
-        if not self.args.connect and not self.args.start_session:
-            self.parser.error(_("You need to use either --connect or --start-session"))
-        self.host.quit()
-
-class ProfileDisconnect(base.CommandBase):
-
-    def __init__(self, host):
-        super(ProfileDisconnect, self).__init__(host, 'disconnect', need_connect=False, help=('disconnect a profile'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.disconnect(self.args.profile)
-        except Exception as e:
-            self.disp(f"can't disconnect profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class ProfileCreate(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileCreate, self).__init__(
-            host, 'create', use_profile=False, help=('create a new profile'))
-
-    def add_parser_options(self):
-        self.parser.add_argument('profile', type=str, help=_('the name of the profile'))
-        self.parser.add_argument(
-            '-p', '--password', type=str, default='',
-            help=_('the password of the profile'))
-        self.parser.add_argument(
-            '-j', '--jid', type=str, help=_('the jid of the profile'))
-        self.parser.add_argument(
-            '-x', '--xmpp-password', type=str,
-            help=_(
-                'the password of the XMPP account (use profile password if not specified)'
-            ),
-            metavar='PASSWORD')
-        self.parser.add_argument(
-            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
-            const=C.BOOL_TRUE,
-            help=_('connect this profile automatically when backend starts')
-        )
-        self.parser.add_argument(
-            '-C', '--component', default='',
-            help=_('set to component import name (entry point) if this is a component'))
-
-    async def start(self):
-        """Create a new profile"""
-        if self.args.profile in await self.host.bridge.profiles_list_get():
-            self.disp(f"Profile {self.args.profile} already exists.", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERROR)
-        try:
-            await self.host.bridge.profile_create(
-                self.args.profile, self.args.password, self.args.component)
-        except Exception as e:
-            self.disp(f"can't create profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        try:
-            await self.host.bridge.profile_start_session(
-                self.args.password, self.args.profile)
-        except Exception as e:
-            self.disp(f"can't start profile session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if self.args.jid:
-            await self.host.bridge.param_set(
-                "JabberID", self.args.jid, "Connection", profile_key=self.args.profile)
-        xmpp_pwd = self.args.password or self.args.xmpp_password
-        if xmpp_pwd:
-            await self.host.bridge.param_set(
-                "Password", xmpp_pwd, "Connection", profile_key=self.args.profile)
-
-        if self.args.autoconnect is not None:
-            await self.host.bridge.param_set(
-                "autoconnect_backend", self.args.autoconnect, "Connection",
-                profile_key=self.args.profile)
-
-        self.disp(f'profile {self.args.profile} created successfully', 1)
-        self.host.quit()
-
-
-class ProfileDefault(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileDefault, self).__init__(
-            host, 'default', use_profile=False, help=('print default profile'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        print(await self.host.bridge.profile_name_get('@DEFAULT@'))
-        self.host.quit()
-
-
-class ProfileDelete(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileDelete, self).__init__(host, 'delete', use_profile=False, help=('delete a profile'))
-
-    def add_parser_options(self):
-        self.parser.add_argument('profile', type=str, help=PROFILE_HELP)
-        self.parser.add_argument('-f', '--force', action='store_true', help=_('delete profile without confirmation'))
-
-    async def start(self):
-        if self.args.profile not in await self.host.bridge.profiles_list_get():
-            log.error(f"Profile {self.args.profile} doesn't exist.")
-            self.host.quit(C.EXIT_NOT_FOUND)
-        if not self.args.force:
-            message = f"Are you sure to delete profile [{self.args.profile}] ?"
-            cancel_message = "Profile deletion cancelled"
-            await self.host.confirm_or_quit(message, cancel_message)
-
-        await self.host.bridge.profile_delete_async(self.args.profile)
-        self.host.quit()
-
-
-class ProfileInfo(base.CommandBase):
-
-    def __init__(self, host):
-        super(ProfileInfo, self).__init__(
-            host, 'info', need_connect=False, use_output=C.OUTPUT_DICT,
-            help=_('get information about a profile'))
-        self.to_show = [(_("jid"), "Connection", "JabberID"),]
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            '--show-password', action='store_true',
-            help=_('show the XMPP password IN CLEAR TEXT'))
-
-    async def start(self):
-        if self.args.show_password:
-            self.to_show.append((_("XMPP password"), "Connection", "Password"))
-        self.to_show.append((_("autoconnect (backend)"), "Connection",
-                                "autoconnect_backend"))
-        data = {}
-        for label, category, name in self.to_show:
-            try:
-                value = await self.host.bridge.param_get_a_async(
-                    name, category, profile_key=self.host.profile)
-            except Exception as e:
-                self.disp(f"can't get {name}/{category} param: {e}", error=True)
-            else:
-                data[label] = value
-
-        await self.output(data)
-        self.host.quit()
-
-
-class ProfileList(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileList, self).__init__(
-            host, 'list', use_profile=False, use_output='list', help=('list profiles'))
-
-    def add_parser_options(self):
-        group = self.parser.add_mutually_exclusive_group()
-        group.add_argument(
-            '-c', '--clients', action='store_true', help=_('get clients profiles only'))
-        group.add_argument(
-            '-C', '--components', action='store_true',
-            help=('get components profiles only'))
-
-    async def start(self):
-        if self.args.clients:
-            clients, components = True, False
-        elif self.args.components:
-            clients, components = False, True
-        else:
-            clients, components = True, True
-        await self.output(await self.host.bridge.profiles_list_get(clients, components))
-        self.host.quit()
-
-
-class ProfileModify(base.CommandBase):
-
-    def __init__(self, host):
-        super(ProfileModify, self).__init__(
-            host, 'modify', need_connect=False, help=_('modify an existing profile'))
-
-    def add_parser_options(self):
-        profile_pwd_group = self.parser.add_mutually_exclusive_group()
-        profile_pwd_group.add_argument(
-            '-w', '--password', help=_('change the password of the profile'))
-        profile_pwd_group.add_argument(
-            '--disable-password', action='store_true',
-            help=_('disable profile password (dangerous!)'))
-        self.parser.add_argument('-j', '--jid', help=_('the jid of the profile'))
-        self.parser.add_argument(
-            '-x', '--xmpp-password', help=_('change the password of the XMPP account'),
-            metavar='PASSWORD')
-        self.parser.add_argument(
-            '-D', '--default', action='store_true', help=_('set as default profile'))
-        self.parser.add_argument(
-            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
-            const=C.BOOL_TRUE,
-            help=_('connect this profile automatically when backend starts')
-        )
-
-    async def start(self):
-        if self.args.disable_password:
-            self.args.password = ''
-        if self.args.password is not None:
-            await self.host.bridge.param_set(
-                "Password", self.args.password, "General", profile_key=self.host.profile)
-        if self.args.jid is not None:
-            await self.host.bridge.param_set(
-                "JabberID", self.args.jid, "Connection", profile_key=self.host.profile)
-        if self.args.xmpp_password is not None:
-            await self.host.bridge.param_set(
-                "Password", self.args.xmpp_password, "Connection",
-                profile_key=self.host.profile)
-        if self.args.default:
-            await self.host.bridge.profile_set_default(self.host.profile)
-        if self.args.autoconnect is not None:
-            await self.host.bridge.param_set(
-                "autoconnect_backend", self.args.autoconnect, "Connection",
-                profile_key=self.host.profile)
-
-        self.host.quit()
-
-
-class Profile(base.CommandBase):
-    subcommands = (
-        ProfileConnect, ProfileDisconnect, ProfileCreate, ProfileDefault, ProfileDelete,
-        ProfileInfo, ProfileList, ProfileModify)
-
-    def __init__(self, host):
-        super(Profile, self).__init__(
-            host, 'profile', use_profile=False, help=_('profile commands'))
--- a/libervia/frontends/jp/cmd_pubsub.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3030 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import argparse
-import os.path
-import re
-import sys
-import subprocess
-import asyncio
-import json
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import common
-from libervia.frontends.jp import arg_tools
-from libervia.frontends.jp import xml_tools
-from functools import partial
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common import uri
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import date_utils
-from libervia.frontends.tools import jid, strings
-from libervia.frontends.bridge.bridge_frontend import BridgeException
-
-__commands__ = ["Pubsub"]
-
-PUBSUB_TMP_DIR = "pubsub"
-PUBSUB_SCHEMA_TMP_DIR = PUBSUB_TMP_DIR + "_schema"
-ALLOWED_SUBSCRIPTIONS_OWNER = ("subscribed", "pending", "none")
-
-# TODO: need to split this class in several modules, plugin should handle subcommands
-
-
-class NodeInfo(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "info",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("retrieve node configuration"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            action="append",
-            dest="keys",
-            help=_("data key to filter"),
-        )
-
-    def remove_prefix(self, key):
-        return key[7:] if key.startswith("pubsub#") else key
-
-    def filter_key(self, key):
-        return any((key == k or key == "pubsub#" + k) for k in self.args.keys)
-
-    async def start(self):
-        try:
-            config_dict = await self.host.bridge.ps_node_configuration_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found":
-                self.disp(
-                    f"The node {self.args.node} doesn't exist on {self.args.service}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't get node configuration: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            key_filter = (lambda k: True) if not self.args.keys else self.filter_key
-            config_dict = {
-                self.remove_prefix(k): v for k, v in config_dict.items() if key_filter(k)
-            }
-            await self.output(config_dict)
-            self.host.quit()
-
-
-class NodeCreate(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "create",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("create a node"),
-        )
-
-    @staticmethod
-    def add_node_config_options(parser):
-        parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            default=[],
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set"),
-        )
-        parser.add_argument(
-            "-F",
-            "--full-prefix",
-            action="store_true",
-            help=_('don\'t prepend "pubsub#" prefix to field names'),
-        )
-
-    def add_parser_options(self):
-        self.add_node_config_options(self.parser)
-
-    @staticmethod
-    def get_config_options(args):
-        if not args.full_prefix:
-            return {"pubsub#" + k: v for k, v in args.fields}
-        else:
-            return dict(args.fields)
-
-    async def start(self):
-        options = self.get_config_options(self.args)
-        try:
-            node_id = await self.host.bridge.ps_node_create(
-                self.args.service,
-                self.args.node,
-                options,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't create node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if self.host.verbosity:
-                announce = _("node created successfully: ")
-            else:
-                announce = ""
-            self.disp(announce + node_id)
-            self.host.quit()
-
-
-class NodePurge(base.CommandBase):
-    def __init__(self, host):
-        super(NodePurge, self).__init__(
-            host,
-            "purge",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("purge a node (i.e. remove all items from it)"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("purge node without confirmation"),
-        )
-
-    async def start(self):
-        if not self.args.force:
-            if not self.args.service:
-                message = _(
-                    "Are you sure to purge PEP node [{node}]? This will "
-                    "delete ALL items from it!"
-                ).format(node=self.args.node)
-            else:
-                message = _(
-                    "Are you sure to delete node [{node}] on service "
-                    "[{service}]? This will delete ALL items from it!"
-                ).format(node=self.args.node, service=self.args.service)
-            await self.host.confirm_or_quit(message, _("node purge cancelled"))
-
-        try:
-            await self.host.bridge.ps_node_purge(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't purge node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("node [{node}] purged successfully").format(node=self.args.node))
-            self.host.quit()
-
-
-class NodeDelete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("delete a node"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("delete node without confirmation"),
-        )
-
-    async def start(self):
-        if not self.args.force:
-            if not self.args.service:
-                message = _("Are you sure to delete PEP node [{node}] ?").format(
-                    node=self.args.node
-                )
-            else:
-                message = _(
-                    "Are you sure to delete node [{node}] on " "service [{service}]?"
-                ).format(node=self.args.node, service=self.args.service)
-            await self.host.confirm_or_quit(message, _("node deletion cancelled"))
-
-        try:
-            await self.host.bridge.ps_node_delete(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't delete node: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("node [{node}] deleted successfully").format(node=self.args.node))
-            self.host.quit()
-
-
-class NodeSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set node configuration"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            required=True,
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set (required)"),
-        )
-        self.parser.add_argument(
-            "-F",
-            "--full-prefix",
-            action="store_true",
-            help=_('don\'t prepend "pubsub#" prefix to field names'),
-        )
-
-    def get_key_name(self, k):
-        if self.args.full_prefix or k.startswith("pubsub#"):
-            return k
-        else:
-            return "pubsub#" + k
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_node_configuration_set(
-                self.args.service,
-                self.args.node,
-                {self.get_key_name(k): v for k, v in self.args.fields},
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set node configuration: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("node configuration successful"), 1)
-            self.host.quit()
-
-
-class NodeImport(base.CommandBase):
-    def __init__(self, host):
-        super(NodeImport, self).__init__(
-            host,
-            "import",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("import raw XML to a node"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--admin",
-            action="store_true",
-            help=_("do a pubsub admin request, needed to change publisher"),
-        )
-        self.parser.add_argument(
-            "import_file",
-            type=argparse.FileType(),
-            help=_(
-                "path to the XML file with data to import. The file must contain "
-                "whole XML of each item to import."
-            ),
-        )
-
-    async def start(self):
-        try:
-            element, etree = xml_tools.etree_parse(
-                self, self.args.import_file, reraise=True
-            )
-        except Exception as e:
-            from lxml.etree import XMLSyntaxError
-
-            if isinstance(e, XMLSyntaxError) and e.code == 5:
-                # we have extra content, this probaby means that item are not wrapped
-                # so we wrap them here and try again
-                self.args.import_file.seek(0)
-                xml_buf = "<import>" + self.args.import_file.read() + "</import>"
-                element, etree = xml_tools.etree_parse(self, xml_buf)
-
-                # we reverse element as we expect to have most recently published element first
-                # TODO: make this more explicit and add an option
-        element[:] = reversed(element)
-
-        if not all([i.tag == "{http://jabber.org/protocol/pubsub}item" for i in element]):
-            self.disp(
-                _("You are not using list of pubsub items, we can't import this file"),
-                error=True,
-            )
-            self.host.quit(C.EXIT_DATA_ERROR)
-            return
-
-        items = [etree.tostring(i, encoding="unicode") for i in element]
-        if self.args.admin:
-            method = self.host.bridge.ps_admin_items_send
-        else:
-            self.disp(
-                _(
-                    "Items are imported without using admin mode, publisher can't "
-                    "be changed"
-                )
-            )
-            method = self.host.bridge.ps_items_send
-
-        try:
-            items_ids = await method(
-                self.args.service,
-                self.args.node,
-                items,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if items_ids:
-                self.disp(
-                    _("items published with id(s) {items_ids}").format(
-                        items_ids=", ".join(items_ids)
-                    )
-                )
-            else:
-                self.disp(_("items published"))
-            self.host.quit()
-
-
-class NodeAffiliationsGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("retrieve node affiliations (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            affiliations = await self.host.bridge.ps_node_affiliations_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get node affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(affiliations)
-            self.host.quit()
-
-
-class NodeAffiliationsSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set affiliations (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
-        #      (used to construct dicts) don't work with positional arguments
-        self.parser.add_argument(
-            "-a",
-            "--affiliation",
-            dest="affiliations",
-            metavar=("JID", "AFFILIATION"),
-            required=True,
-            action="append",
-            nargs=2,
-            help=_("entity/affiliation couple(s)"),
-        )
-
-    async def start(self):
-        affiliations = dict(self.args.affiliations)
-        try:
-            await self.host.bridge.ps_node_affiliations_set(
-                self.args.service,
-                self.args.node,
-                affiliations,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set node affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("affiliations have been set"), 1)
-            self.host.quit()
-
-
-class NodeAffiliations(base.CommandBase):
-    subcommands = (NodeAffiliationsGet, NodeAffiliationsSet)
-
-    def __init__(self, host):
-        super(NodeAffiliations, self).__init__(
-            host,
-            "affiliations",
-            use_profile=False,
-            help=_("set or retrieve node affiliations"),
-        )
-
-
-class NodeSubscriptionsGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("retrieve node subscriptions (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--public",
-            action="store_true",
-            help=_("get public subscriptions"),
-        )
-
-    async def start(self):
-        if self.args.public:
-            method = self.host.bridge.ps_public_node_subscriptions_get
-        else:
-            method = self.host.bridge.ps_node_subscriptions_get
-        try:
-            subscriptions = await method(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get node subscriptions: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(subscriptions)
-            self.host.quit()
-
-
-class StoreSubscriptionAction(argparse.Action):
-    """Action which handle subscription parameter for owner
-
-    list is given by pairs: jid and subscription state
-    if subscription state is not specified, it default to "subscribed"
-    """
-
-    def __call__(self, parser, namespace, values, option_string):
-        dest_dict = getattr(namespace, self.dest)
-        while values:
-            jid_s = values.pop(0)
-            try:
-                subscription = values.pop(0)
-            except IndexError:
-                subscription = "subscribed"
-            if subscription not in ALLOWED_SUBSCRIPTIONS_OWNER:
-                parser.error(
-                    _("subscription must be one of {}").format(
-                        ", ".join(ALLOWED_SUBSCRIPTIONS_OWNER)
-                    )
-                )
-            dest_dict[jid_s] = subscription
-
-
-class NodeSubscriptionsSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set/modify subscriptions (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
-        #      (uses to construct dicts) don't work with positional arguments
-        self.parser.add_argument(
-            "-S",
-            "--subscription",
-            dest="subscriptions",
-            default={},
-            nargs="+",
-            metavar=("JID [SUSBSCRIPTION]"),
-            required=True,
-            action=StoreSubscriptionAction,
-            help=_("entity/subscription couple(s)"),
-        )
-
-    async def start(self):
-        try:
-            self.host.bridge.ps_node_subscriptions_set(
-                self.args.service,
-                self.args.node,
-                self.args.subscriptions,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set node subscriptions: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("subscriptions have been set"), 1)
-            self.host.quit()
-
-
-class NodeSubscriptions(base.CommandBase):
-    subcommands = (NodeSubscriptionsGet, NodeSubscriptionsSet)
-
-    def __init__(self, host):
-        super(NodeSubscriptions, self).__init__(
-            host,
-            "subscriptions",
-            use_profile=False,
-            help=_("get or modify node subscriptions"),
-        )
-
-
-class NodeSchemaSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set/replace a schema"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("schema", help=_("schema to set (must be XML)"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_schema_set(
-                self.args.service,
-                self.args.node,
-                self.args.schema,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set schema: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("schema has been set"), 1)
-            self.host.quit()
-
-
-class NodeSchemaEdit(base.CommandBase, common.BaseEdit):
-    use_items = False
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_draft=True,
-            use_verbose=True,
-            help=_("edit a schema"),
-        )
-        common.BaseEdit.__init__(self, self.host, PUBSUB_SCHEMA_TMP_DIR)
-
-    def add_parser_options(self):
-        pass
-
-    async def publish(self, schema):
-        try:
-            await self.host.bridge.ps_schema_set(
-                self.args.service,
-                self.args.node,
-                schema,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set schema: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("schema has been set"), 1)
-            self.host.quit()
-
-    async def ps_schema_get_cb(self, schema):
-        try:
-            from lxml import etree
-        except ImportError:
-            self.disp(
-                "lxml module must be installed to use edit, please install it "
-                'with "pip install lxml"',
-                error=True,
-            )
-            self.host.quit(1)
-        content_file_obj, content_file_path = self.get_tmp_file()
-        schema = schema.strip()
-        if schema:
-            parser = etree.XMLParser(remove_blank_text=True)
-            schema_elt = etree.fromstring(schema, parser)
-            content_file_obj.write(
-                etree.tostring(schema_elt, encoding="utf-8", pretty_print=True)
-            )
-            content_file_obj.seek(0)
-        await self.run_editor(
-            "pubsub_schema_editor_args", content_file_path, content_file_obj
-        )
-
-    async def start(self):
-        try:
-            schema = await self.host.bridge.ps_schema_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                schema = ""
-            else:
-                self.disp(f"can't edit schema: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        await self.ps_schema_get_cb(schema)
-
-
-class NodeSchemaGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_XML,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("get schema"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            schema = await self.host.bridge.ps_schema_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                schema = None
-            else:
-                self.disp(f"can't get schema: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if schema:
-            await self.output(schema)
-            self.host.quit()
-        else:
-            self.disp(_("no schema found"), 1)
-            self.host.quit(C.EXIT_NOT_FOUND)
-
-
-class NodeSchema(base.CommandBase):
-    subcommands = (NodeSchemaSet, NodeSchemaEdit, NodeSchemaGet)
-
-    def __init__(self, host):
-        super(NodeSchema, self).__init__(
-            host, "schema", use_profile=False, help=_("data schema manipulation")
-        )
-
-
-class Node(base.CommandBase):
-    subcommands = (
-        NodeInfo,
-        NodeCreate,
-        NodePurge,
-        NodeDelete,
-        NodeSet,
-        NodeImport,
-        NodeAffiliations,
-        NodeSubscriptions,
-        NodeSchema,
-    )
-
-    def __init__(self, host):
-        super(Node, self).__init__(
-            host, "node", use_profile=False, help=_("node handling")
-        )
-
-
-class CacheGet(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_XML,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
-            help=_("get pubsub item(s) from cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-S",
-            "--sub-id",
-            default="",
-            help=_("subscription id"),
-        )
-
-    async def start(self):
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_cache_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    self.args.sub_id,
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except BridgeException as e:
-            if e.classname == "NotFound":
-                self.disp(
-                    f"The node {self.args.node} from {self.args.service} is not in cache "
-                    f"for {self.profile}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't get pubsub items from cache: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            await self.output(ps_result["items"])
-            self.host.quit(C.EXIT_OK)
-
-
-class CacheSync(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "sync",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("(re)synchronise a pubsub node"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_cache_sync(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                self.disp(
-                    f"The node {self.args.node} doesn't exist on {self.args.service}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't synchronise pubsub node: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class CachePurge(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "purge",
-            use_profile=False,
-            help=_("purge (delete) items from cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-s", "--service", action="append", metavar="JID", dest="services",
-            help="purge items only for these services. If not specified, items from ALL "
-            "services will be purged. May be used several times."
-        )
-        self.parser.add_argument(
-            "-n", "--node", action="append", dest="nodes",
-            help="purge items only for these nodes. If not specified, items from ALL "
-            "nodes will be purged. May be used several times."
-        )
-        self.parser.add_argument(
-            "-p", "--profile", action="append", dest="profiles",
-            help="purge items only for these profiles. If not specified, items from ALL "
-            "profiles will be purged. May be used several times."
-        )
-        self.parser.add_argument(
-            "-b", "--updated-before", type=base.date_decoder, metavar="TIME_PATTERN",
-            help="purge items which have been last updated before given time."
-        )
-        self.parser.add_argument(
-            "-C", "--created-before", type=base.date_decoder, metavar="TIME_PATTERN",
-            help="purge items which have been last created before given time."
-        )
-        self.parser.add_argument(
-            "-t", "--type", action="append", dest="types",
-            help="purge items flagged with TYPE. May be used several times."
-        )
-        self.parser.add_argument(
-            "-S", "--subtype", action="append", dest="subtypes",
-            help="purge items flagged with SUBTYPE. May be used several times."
-        )
-        self.parser.add_argument(
-            "-f", "--force", action="store_true",
-            help=_("purge items without confirmation")
-        )
-
-    async def start(self):
-        if not self.args.force:
-            await self.host.confirm_or_quit(
-                _(
-                    "Are you sure to purge items from cache? You'll have to bypass cache "
-                    "or resynchronise nodes to access deleted items again."
-                ),
-                _("Items purgins has been cancelled.")
-            )
-        purge_data = {}
-        for key in (
-                "services", "nodes", "profiles", "updated_before", "created_before",
-                "types", "subtypes"
-        ):
-            value = getattr(self.args, key)
-            if value is not None:
-                purge_data[key] = value
-        try:
-            await self.host.bridge.ps_cache_purge(
-                data_format.serialise(
-                    purge_data
-                )
-            )
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class CacheReset(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "reset",
-            use_profile=False,
-            help=_("remove everything from cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true",
-            help=_("reset cache without confirmation")
-        )
-
-    async def start(self):
-        if not self.args.force:
-            await self.host.confirm_or_quit(
-                _(
-                    "Are you sure to reset cache? All nodes and items will be removed "
-                    "from it, then it will be progressively refilled as if it were new. "
-                    "This may be resources intensive."
-                ),
-                _("Pubsub cache reset has been cancelled.")
-            )
-        try:
-            await self.host.bridge.ps_cache_reset()
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class CacheSearch(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {
-            "default": self.default_output,
-            "xml": self.xml_output,
-            "xml-raw": self.xml_raw_output,
-        }
-        super().__init__(
-            host,
-            "search",
-            use_profile=False,
-            use_output=C.OUTPUT_LIST_DICT,
-            extra_outputs=extra_outputs,
-            help=_("search for pubsub items in cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--fts", help=_("Full-Text Search query"), metavar="FTS_QUERY"
-        )
-        self.parser.add_argument(
-            "-p", "--profile", action="append", dest="profiles", metavar="PROFILE",
-            help="search items only from these profiles. May be used several times."
-        )
-        self.parser.add_argument(
-            "-s", "--service", action="append", dest="services", metavar="SERVICE",
-            help="items must be from specified service. May be used several times."
-        )
-        self.parser.add_argument(
-            "-n", "--node", action="append", dest="nodes", metavar="NODE",
-            help="items must be in the specified node. May be used several times."
-        )
-        self.parser.add_argument(
-            "-t", "--type", action="append", dest="types", metavar="TYPE",
-            help="items must be of specified type. May be used several times."
-        )
-        self.parser.add_argument(
-            "-S", "--subtype", action="append", dest="subtypes", metavar="SUBTYPE",
-            help="items must be of specified subtype. May be used several times."
-        )
-        self.parser.add_argument(
-            "-P", "--payload", action="store_true", help=_("include item XML payload")
-        )
-        self.parser.add_argument(
-            "-o", "--order-by", action="append", nargs="+",
-            metavar=("ORDER", "[FIELD] [DIRECTION]"),
-            help=_("how items must be ordered. May be used several times.")
-        )
-        self.parser.add_argument(
-            "-l", "--limit", type=int, help=_("maximum number of items to return")
-        )
-        self.parser.add_argument(
-            "-i", "--index", type=int, help=_("return results starting from this index")
-        )
-        self.parser.add_argument(
-            "-F",
-            "--field",
-            action="append",
-            nargs=3,
-            dest="fields",
-            default=[],
-            metavar=("PATH", "OPERATOR", "VALUE"),
-            help=_("parsed data field filter. May be used several times."),
-        )
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            action="append",
-            dest="keys",
-            metavar="KEY",
-            help=_(
-                "data key(s) to display. May be used several times. DEFAULT: show all "
-                "keys"
-            ),
-        )
-
-    async def start(self):
-        query = {}
-        for arg in ("fts", "profiles", "services", "nodes", "types", "subtypes"):
-            value = getattr(self.args, arg)
-            if value:
-                if arg in ("types", "subtypes"):
-                    # empty string is used to find items without type and/or subtype
-                    value = [v or None for v in value]
-                query[arg] = value
-        for arg in ("limit", "index"):
-            value = getattr(self.args, arg)
-            if value is not None:
-                query[arg] = value
-        if self.args.order_by is not None:
-            for order_data in self.args.order_by:
-                order, *args = order_data
-                if order == "field":
-                    if not args:
-                        self.parser.error(_("field data must be specified in --order-by"))
-                    elif len(args) == 1:
-                        path = args[0]
-                        direction = "asc"
-                    elif len(args) == 2:
-                        path, direction = args
-                    else:
-                        self.parser.error(_(
-                            "You can't specify more that 2 arguments for a field in "
-                            "--order-by"
-                        ))
-                    try:
-                        path = json.loads(path)
-                    except json.JSONDecodeError:
-                        pass
-                    order_query = {
-                        "path": path,
-                    }
-                else:
-                    order_query = {
-                        "order": order
-                    }
-                    if not args:
-                        direction = "asc"
-                    elif len(args) == 1:
-                        direction = args[0]
-                    else:
-                        self.parser.error(_(
-                            "there are too many arguments in --order-by option"
-                        ))
-                if direction.lower() not in ("asc", "desc"):
-                    self.parser.error(_("invalid --order-by direction: {direction!r}"))
-                order_query["direction"] = direction
-                query.setdefault("order-by", []).append(order_query)
-
-        if self.args.fields:
-            parsed = []
-            for field in self.args.fields:
-                path, operator, value = field
-                try:
-                    path = json.loads(path)
-                except json.JSONDecodeError:
-                    # this is not a JSON encoded value, we keep it as a string
-                    pass
-
-                if not isinstance(path, list):
-                    path = [path]
-
-                # handling of TP(<time pattern>)
-                if operator in (">", "gt", "<", "le", "between"):
-                    def datetime_sub(match):
-                        return str(date_utils.date_parse_ext(
-                            match.group(1), default_tz=date_utils.TZ_LOCAL
-                        ))
-                    value = re.sub(r"\bTP\(([^)]+)\)", datetime_sub, value)
-
-                try:
-                    value = json.loads(value)
-                except json.JSONDecodeError:
-                    # not JSON, as above we keep it as string
-                    pass
-
-                if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"):
-                    if not isinstance(value, list):
-                        value = [value]
-
-                parsed.append({
-                    "path": path,
-                    "op": operator,
-                    "value": value
-                })
-
-            query["parsed"] = parsed
-
-        if self.args.payload or "xml" in self.args.output:
-            query["with_payload"] = True
-            if self.args.keys:
-                self.args.keys.append("item_payload")
-        try:
-            found_items = data_format.deserialise(
-                await self.host.bridge.ps_cache_search(
-                    data_format.serialise(query)
-                ),
-                type_check=list,
-            )
-        except BridgeException as e:
-            self.disp(f"can't search for pubsub items in cache: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            if self.args.keys:
-                found_items = [
-                    {k: v for k,v in item.items() if k in self.args.keys}
-                    for item in found_items
-                ]
-            await self.output(found_items)
-            self.host.quit(C.EXIT_OK)
-
-    def default_output(self, found_items):
-        for item in found_items:
-            for field in ("created", "published", "updated"):
-                try:
-                    timestamp = item[field]
-                except KeyError:
-                    pass
-                else:
-                    try:
-                        item[field] = common.format_time(timestamp)
-                    except ValueError:
-                        pass
-        self.host._outputs[C.OUTPUT_LIST_DICT]["simple"]["callback"](found_items)
-
-    def xml_output(self, found_items):
-        """Output prettified item payload"""
-        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML]["callback"]
-        for item in found_items:
-            cb(item["item_payload"])
-
-    def xml_raw_output(self, found_items):
-        """Output item payload without prettifying"""
-        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML_RAW]["callback"]
-        for item in found_items:
-            cb(item["item_payload"])
-
-
-class Cache(base.CommandBase):
-    subcommands = (
-        CacheGet,
-        CacheSync,
-        CachePurge,
-        CacheReset,
-        CacheSearch,
-    )
-
-    def __init__(self, host):
-        super(Cache, self).__init__(
-            host, "cache", use_profile=False, help=_("pubsub cache handling")
-        )
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            use_quiet=True,
-            pubsub_flags={C.NODE},
-            help=_("publish a new item or update an existing one"),
-        )
-
-    def add_parser_options(self):
-        NodeCreate.add_node_config_options(self.parser)
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the blog item")
-        )
-        self.parser.add_argument(
-            "--encrypt-for",
-            metavar="JID",
-            action="append",
-            help=_("encrypt a single item for")
-        )
-        self.parser.add_argument(
-            "-X",
-            "--sign",
-            action="store_true",
-            help=_("cryptographically sign the blog post")
-        )
-        self.parser.add_argument(
-            "item",
-            nargs="?",
-            default="",
-            help=_("id, URL of the item to update, keyword, or nothing for new item"),
-        )
-
-    async def start(self):
-        element, etree = xml_tools.etree_parse(self, sys.stdin)
-        element = xml_tools.get_payload(self, element)
-        payload = etree.tostring(element, encoding="unicode")
-        extra = {}
-        if self.args.encrypt:
-            extra["encrypted"] = True
-        if self.args.encrypt_for:
-            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
-        if self.args.sign:
-            extra["signed"] = True
-        publish_options = NodeCreate.get_config_options(self.args)
-        if publish_options:
-            extra["publish_options"] = publish_options
-
-        try:
-            published_id = await self.host.bridge.ps_item_send(
-                self.args.service,
-                self.args.node,
-                payload,
-                self.args.item,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't send item: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if published_id:
-                if self.args.quiet:
-                    self.disp(published_id, end="")
-                else:
-                    self.disp(f"Item published at {published_id}")
-            else:
-                self.disp("Item published")
-            self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_XML,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
-            help=_("get pubsub item(s)"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-S",
-            "--sub-id",
-            default="",
-            help=_("subscription id"),
-        )
-        self.parser.add_argument(
-            "--no-decrypt",
-            action="store_true",
-            help=_("don't do automatic decryption of e2ee items"),
-        )
-        #  TODO: a key(s) argument to select keys to display
-
-    async def start(self):
-        extra = {}
-        if self.args.no_decrypt:
-            extra["decrypt"] = False
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_items_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    self.args.sub_id,
-                    self.get_pubsub_extra(extra),
-                    self.profile,
-                )
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                self.disp(
-                    f"The node {self.args.node} doesn't exist on {self.args.service}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't get pubsub items: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            await self.output(ps_result["items"])
-            self.host.quit(C.EXIT_OK)
-
-
-class Delete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.ITEM, C.SINGLE_ITEM},
-            help=_("delete an item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true", help=_("delete without confirmation")
-        )
-        self.parser.add_argument(
-            "--no-notification", dest="notify", action="store_false",
-            help=_("do not send notification (not recommended)")
-        )
-
-    async def start(self):
-        if not self.args.item:
-            self.parser.error(_("You need to specify an item to delete"))
-        if not self.args.force:
-            message = _("Are you sure to delete item {item_id} ?").format(
-                item_id=self.args.item
-            )
-            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
-        try:
-            await self.host.bridge.ps_item_retract(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.notify,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't delete item: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("item {item} has been deleted").format(item=self.args.item))
-            self.host.quit(C.EXIT_OK)
-
-
-class Edit(base.CommandBase, common.BaseEdit):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            use_draft=True,
-            help=_("edit an existing or new pubsub item"),
-        )
-        common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the blog item")
-        )
-        self.parser.add_argument(
-            "--encrypt-for",
-            metavar="JID",
-            action="append",
-            help=_("encrypt a single item for")
-        )
-        self.parser.add_argument(
-            "-X",
-            "--sign",
-            action="store_true",
-            help=_("cryptographically sign the blog post")
-        )
-
-    async def publish(self, content):
-        extra = {}
-        if self.args.encrypt:
-            extra["encrypted"] = True
-        if self.args.encrypt_for:
-            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
-        if self.args.sign:
-            extra["signed"] = True
-        published_id = await self.host.bridge.ps_item_send(
-            self.pubsub_service,
-            self.pubsub_node,
-            content,
-            self.pubsub_item or "",
-            data_format.serialise(extra),
-            self.profile,
-        )
-        if published_id:
-            self.disp("Item published at {pub_id}".format(pub_id=published_id))
-        else:
-            self.disp("Item published")
-
-    async def get_item_data(self, service, node, item):
-        try:
-            from lxml import etree
-        except ImportError:
-            self.disp(
-                "lxml module must be installed to use edit, please install it "
-                'with "pip install lxml"',
-                error=True,
-            )
-            self.host.quit(1)
-        items = [item] if item else []
-        ps_result = data_format.deserialise(
-            await self.host.bridge.ps_items_get(
-                service, node, 1, items, "", data_format.serialise({}), self.profile
-            )
-        )
-        item_raw = ps_result["items"][0]
-        parser = etree.XMLParser(remove_blank_text=True, recover=True)
-        item_elt = etree.fromstring(item_raw, parser)
-        item_id = item_elt.get("id")
-        try:
-            payload = item_elt[0]
-        except IndexError:
-            self.disp(_("Item has not payload"), 1)
-            return "", item_id
-        return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id
-
-    async def start(self):
-        (
-            self.pubsub_service,
-            self.pubsub_node,
-            self.pubsub_item,
-            content_file_path,
-            content_file_obj,
-        ) = await self.get_item_path()
-        await self.run_editor("pubsub_editor_args", content_file_path, content_file_obj)
-        self.host.quit()
-
-
-class Rename(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "rename",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("rename a pubsub item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("new_id", help=_("new item id to use"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_item_rename(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.new_id,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't rename item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("Item renamed")
-            self.host.quit(C.EXIT_OK)
-
-
-class Subscribe(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "subscribe",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("subscribe to a node"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--public",
-            action="store_true",
-            help=_("make the registration visible for everybody"),
-        )
-
-    async def start(self):
-        options = {}
-        if self.args.public:
-            namespaces = await self.host.bridge.namespaces_get()
-            try:
-                ns_pps = namespaces["pps"]
-            except KeyError:
-                self.disp(
-                    "Pubsub Public Subscription plugin is not loaded, can't use --public "
-                    "option, subscription stopped", error=True
-                )
-                self.host.quit(C.EXIT_MISSING_FEATURE)
-            else:
-                options[f"{{{ns_pps}}}public"] = True
-        try:
-            sub_id = await self.host.bridge.ps_subscribe(
-                self.args.service,
-                self.args.node,
-                data_format.serialise(options),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't subscribe to node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("subscription done"), 1)
-            if sub_id:
-                self.disp(_("subscription id: {sub_id}").format(sub_id=sub_id))
-            self.host.quit()
-
-
-class Unsubscribe(base.CommandBase):
-    # FIXME: check why we get a a NodeNotFound on subscribe just after unsubscribe
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "unsubscribe",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("unsubscribe from a node"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_unsubscribe(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't unsubscribe from node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("subscription removed"), 1)
-            self.host.quit()
-
-
-class Subscriptions(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "subscriptions",
-            use_output=C.OUTPUT_LIST_DICT,
-            use_pubsub=True,
-            help=_("retrieve all subscriptions on a service"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--public",
-            action="store_true",
-            help=_("get public subscriptions"),
-        )
-
-    async def start(self):
-        if self.args.public:
-            method = self.host.bridge.ps_public_subscriptions_get
-        else:
-            method = self.host.bridge.ps_subscriptions_get
-        try:
-            subscriptions = data_format.deserialise(
-                await method(
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                ),
-                type_check=list
-            )
-        except Exception as e:
-            self.disp(_("can't retrieve subscriptions: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(subscriptions)
-            self.host.quit()
-
-
-class Affiliations(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "affiliations",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            help=_("retrieve all affiliations on a service"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            affiliations = await self.host.bridge.ps_affiliations_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get node affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(affiliations)
-            self.host.quit()
-
-
-class Reference(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "reference",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("send a reference/mention to pubsub item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            default="mention",
-            choices=("data", "mention"),
-            help=_("type of reference to send (DEFAULT: mention)"),
-        )
-        self.parser.add_argument(
-            "recipient",
-            help=_("recipient of the reference")
-        )
-
-    async def start(self):
-        service = self.args.service or await self.host.get_profile_jid()
-        if self.args.item:
-            anchor = uri.build_xmpp_uri(
-                "pubsub", path=service, node=self.args.node, item=self.args.item
-            )
-        else:
-            anchor = uri.build_xmpp_uri("pubsub", path=service, node=self.args.node)
-
-        try:
-            await self.host.bridge.reference_send(
-                self.args.recipient,
-                anchor,
-                self.args.type,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send reference: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Search(base.CommandBase):
-    """This command do a search without using MAM
-
-    This commands checks every items it finds by itself,
-    so it may be heavy in resources both for server and client
-    """
-
-    RE_FLAGS = re.MULTILINE | re.UNICODE
-    EXEC_ACTIONS = ("exec", "external")
-
-    def __init__(self, host):
-        # FIXME: C.NO_MAX is not needed here, and this can be globally removed from consts
-        #        the only interest is to change the help string, but this can be explained
-        #        extensively in man pages (max is for each node found)
-        base.CommandBase.__init__(
-            self,
-            host,
-            "search",
-            use_output=C.OUTPUT_XML,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS, C.NO_MAX},
-            use_verbose=True,
-            help=_("search items corresponding to filters"),
-        )
-
-    @property
-    def etree(self):
-        """load lxml.etree only if needed"""
-        if self._etree is None:
-            from lxml import etree
-
-            self._etree = etree
-        return self._etree
-
-    def filter_opt(self, value, type_):
-        return (type_, value)
-
-    def filter_flag(self, value, type_):
-        value = C.bool(value)
-        return (type_, value)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-D",
-            "--max-depth",
-            type=int,
-            default=0,
-            help=_(
-                "maximum depth of recursion (will search linked nodes if > 0, "
-                "DEFAULT: 0)"
-            ),
-        )
-        self.parser.add_argument(
-            "-M",
-            "--node-max",
-            type=int,
-            default=30,
-            help=_(
-                "maximum number of items to get per node ({} to get all items, "
-                "DEFAULT: 30)".format(C.NO_LIMIT)
-            ),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar="NAME NAMESPACE",
-            help=_("namespace to use for xpath"),
-        )
-
-        # filters
-        filter_text = partial(self.filter_opt, type_="text")
-        filter_re = partial(self.filter_opt, type_="regex")
-        filter_xpath = partial(self.filter_opt, type_="xpath")
-        filter_python = partial(self.filter_opt, type_="python")
-        filters = self.parser.add_argument_group(
-            _("filters"),
-            _("only items corresponding to following filters will be kept"),
-        )
-        filters.add_argument(
-            "-t",
-            "--text",
-            action="append",
-            dest="filters",
-            type=filter_text,
-            metavar="TEXT",
-            help=_("full text filter, item must contain this string (XML included)"),
-        )
-        filters.add_argument(
-            "-r",
-            "--regex",
-            action="append",
-            dest="filters",
-            type=filter_re,
-            metavar="EXPRESSION",
-            help=_("like --text but using a regular expression"),
-        )
-        filters.add_argument(
-            "-x",
-            "--xpath",
-            action="append",
-            dest="filters",
-            type=filter_xpath,
-            metavar="XPATH",
-            help=_("filter items which has elements matching this xpath"),
-        )
-        filters.add_argument(
-            "-P",
-            "--python",
-            action="append",
-            dest="filters",
-            type=filter_python,
-            metavar="PYTHON_CODE",
-            help=_(
-                "Python expression which much return a bool (True to keep item, "
-                'False to reject it). "item" is raw text item, "item_xml" is '
-                "lxml's etree.Element"
-            ),
-        )
-
-        # filters flags
-        flag_case = partial(self.filter_flag, type_="ignore-case")
-        flag_invert = partial(self.filter_flag, type_="invert")
-        flag_dotall = partial(self.filter_flag, type_="dotall")
-        flag_matching = partial(self.filter_flag, type_="only-matching")
-        flags = self.parser.add_argument_group(
-            _("filters flags"),
-            _("filters modifiers (change behaviour of following filters)"),
-        )
-        flags.add_argument(
-            "-C",
-            "--ignore-case",
-            action="append",
-            dest="filters",
-            type=flag_case,
-            const=("ignore-case", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("(don't) ignore case in following filters (DEFAULT: case sensitive)"),
-        )
-        flags.add_argument(
-            "-I",
-            "--invert",
-            action="append",
-            dest="filters",
-            type=flag_invert,
-            const=("invert", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("(don't) invert effect of following filters (DEFAULT: don't invert)"),
-        )
-        flags.add_argument(
-            "-A",
-            "--dot-all",
-            action="append",
-            dest="filters",
-            type=flag_dotall,
-            const=("dotall", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("(don't) use DOTALL option for regex (DEFAULT: don't use)"),
-        )
-        flags.add_argument(
-            "-k",
-            "--only-matching",
-            action="append",
-            dest="filters",
-            type=flag_matching,
-            const=("only-matching", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("keep only the matching part of the item"),
-        )
-
-        # action
-        self.parser.add_argument(
-            "action",
-            default="print",
-            nargs="?",
-            choices=("print", "exec", "external"),
-            help=_("action to do on found items (DEFAULT: print)"),
-        )
-        self.parser.add_argument("command", nargs=argparse.REMAINDER)
-
-    async def get_items(self, depth, service, node, items):
-        self.to_get += 1
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_items_get(
-                    service,
-                    node,
-                    self.args.node_max,
-                    items,
-                    "",
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(
-                f"can't get pubsub items at {service} (node: {node}): {e}",
-                error=True,
-            )
-            self.to_get -= 1
-        else:
-            await self.search(ps_result, depth)
-
-    def _check_pubsub_url(self, match, found_nodes):
-        """check that the matched URL is an xmpp: one
-
-        @param found_nodes(list[unicode]): found_nodes
-            this list will be filled while xmpp: URIs are discovered
-        """
-        url = match.group(0)
-        if url.startswith("xmpp"):
-            try:
-                url_data = uri.parse_xmpp_uri(url)
-            except ValueError:
-                return
-            if url_data["type"] == "pubsub":
-                found_node = {"service": url_data["path"], "node": url_data["node"]}
-                if "item" in url_data:
-                    found_node["item"] = url_data["item"]
-                found_nodes.append(found_node)
-
-    async def get_sub_nodes(self, item, depth):
-        """look for pubsub URIs in item, and get_items on the linked nodes"""
-        found_nodes = []
-        checkURI = partial(self._check_pubsub_url, found_nodes=found_nodes)
-        strings.RE_URL.sub(checkURI, item)
-        for data in found_nodes:
-            await self.get_items(
-                depth + 1,
-                data["service"],
-                data["node"],
-                [data["item"]] if "item" in data else [],
-            )
-
-    def parseXml(self, item):
-        try:
-            return self.etree.fromstring(item)
-        except self.etree.XMLSyntaxError:
-            self.disp(
-                _(
-                    "item doesn't looks like XML, you have probably used --only-matching "
-                    "somewhere before and we have no more XML"
-                ),
-                error=True,
-            )
-            self.host.quit(C.EXIT_BAD_ARG)
-
-    def filter(self, item):
-        """apply filters given on command line
-
-        if only-matching is used, item may be modified
-        @return (tuple[bool, unicode]): a tuple with:
-            - keep: True if item passed the filters
-            - item: it is returned in case of modifications
-        """
-        ignore_case = False
-        invert = False
-        dotall = False
-        only_matching = False
-        item_xml = None
-        for type_, value in self.args.filters:
-            keep = True
-
-            ## filters
-
-            if type_ == "text":
-                if ignore_case:
-                    if value.lower() not in item.lower():
-                        keep = False
-                else:
-                    if value not in item:
-                        keep = False
-                if keep and only_matching:
-                    # doesn't really make sens to keep a fixed string
-                    # so we raise an error
-                    self.host.disp(
-                        _("--only-matching used with fixed --text string, are you sure?"),
-                        error=True,
-                    )
-                    self.host.quit(C.EXIT_BAD_ARG)
-            elif type_ == "regex":
-                flags = self.RE_FLAGS
-                if ignore_case:
-                    flags |= re.IGNORECASE
-                if dotall:
-                    flags |= re.DOTALL
-                match = re.search(value, item, flags)
-                keep = match != None
-                if keep and only_matching:
-                    item = match.group()
-                    item_xml = None
-            elif type_ == "xpath":
-                if item_xml is None:
-                    item_xml = self.parseXml(item)
-                try:
-                    elts = item_xml.xpath(value, namespaces=self.args.namespace)
-                except self.etree.XPathEvalError as e:
-                    self.disp(_("can't use xpath: {reason}").format(reason=e), error=True)
-                    self.host.quit(C.EXIT_BAD_ARG)
-                keep = bool(elts)
-                if keep and only_matching:
-                    item_xml = elts[0]
-                    try:
-                        item = self.etree.tostring(item_xml, encoding="unicode")
-                    except TypeError:
-                        # we have a string only, not an element
-                        item = str(item_xml)
-                        item_xml = None
-            elif type_ == "python":
-                if item_xml is None:
-                    item_xml = self.parseXml(item)
-                cmd_ns = {"etree": self.etree, "item": item, "item_xml": item_xml}
-                try:
-                    keep = eval(value, cmd_ns)
-                except SyntaxError as e:
-                    self.disp(str(e), error=True)
-                    self.host.quit(C.EXIT_BAD_ARG)
-
-                    ## flags
-
-            elif type_ == "ignore-case":
-                ignore_case = value
-            elif type_ == "invert":
-                invert = value
-                #  we need to continue, else loop would end here
-                continue
-            elif type_ == "dotall":
-                dotall = value
-            elif type_ == "only-matching":
-                only_matching = value
-            else:
-                raise exceptions.InternalError(
-                    _("unknown filter type {type}").format(type=type_)
-                )
-
-            if invert:
-                keep = not keep
-            if not keep:
-                return False, item
-
-        return True, item
-
-    async def do_item_action(self, item, metadata):
-        """called when item has been kepts and the action need to be done
-
-        @param item(unicode): accepted item
-        """
-        action = self.args.action
-        if action == "print" or self.host.verbosity > 0:
-            try:
-                await self.output(item)
-            except self.etree.XMLSyntaxError:
-                # item is not valid XML, but a string
-                # can happen when --only-matching is used
-                self.disp(item)
-        if action in self.EXEC_ACTIONS:
-            item_elt = self.parseXml(item)
-            if action == "exec":
-                use = {
-                    "service": metadata["service"],
-                    "node": metadata["node"],
-                    "item": item_elt.get("id"),
-                    "profile": self.profile,
-                }
-                # we need to send a copy of self.args.command
-                # else it would be modified
-                parser_args, use_args = arg_tools.get_use_args(
-                    self.host, self.args.command, use, verbose=self.host.verbosity > 1
-                )
-                cmd_args = sys.argv[0:1] + parser_args + use_args
-            else:
-                cmd_args = self.args.command
-
-            self.disp(
-                "COMMAND: {command}".format(
-                    command=" ".join([arg_tools.escape(a) for a in cmd_args])
-                ),
-                2,
-            )
-            if action == "exec":
-                p = await asyncio.create_subprocess_exec(*cmd_args)
-                ret = await p.wait()
-            else:
-                p = await asyncio.create_subprocess_exec(*cmd_args, stdin=subprocess.PIPE)
-                await p.communicate(item.encode(sys.getfilesystemencoding()))
-                ret = p.returncode
-            if ret != 0:
-                self.disp(
-                    A.color(
-                        C.A_FAILURE,
-                        _("executed command failed with exit code {ret}").format(ret=ret),
-                    )
-                )
-
-    async def search(self, ps_result, depth):
-        """callback of get_items
-
-        this method filters items, get sub nodes if needed,
-        do the requested action, and exit the command when everything is done
-        @param items_data(tuple): result of get_items
-        @param depth(int): current depth level
-            0 for first node, 1 for first children, and so on
-        """
-        for item in ps_result["items"]:
-            if depth < self.args.max_depth:
-                await self.get_sub_nodes(item, depth)
-            keep, item = self.filter(item)
-            if not keep:
-                continue
-            await self.do_item_action(item, ps_result)
-
-            #  we check if we got all get_items results
-        self.to_get -= 1
-        if self.to_get == 0:
-            # yes, we can quit
-            self.host.quit()
-        assert self.to_get > 0
-
-    async def start(self):
-        if self.args.command:
-            if self.args.action not in self.EXEC_ACTIONS:
-                self.parser.error(
-                    _("Command can only be used with {actions} actions").format(
-                        actions=", ".join(self.EXEC_ACTIONS)
-                    )
-                )
-        else:
-            if self.args.action in self.EXEC_ACTIONS:
-                self.parser.error(_("you need to specify a command to execute"))
-        if not self.args.node:
-            # TODO: handle get service affiliations when node is not set
-            self.parser.error(_("empty node is not handled yet"))
-            # to_get is increased on each get and decreased on each answer
-            # when it reach 0 again, the command is finished
-        self.to_get = 0
-        self._etree = None
-        if self.args.filters is None:
-            self.args.filters = []
-        self.args.namespace = dict(
-            self.args.namespace + [("pubsub", "http://jabber.org/protocol/pubsub")]
-        )
-        await self.get_items(0, self.args.service, self.args.node, self.args.items)
-
-
-class Transform(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "transform",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.MULTI_ITEMS},
-            help=_("modify items of a node using an external command/script"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--apply",
-            action="store_true",
-            help=_("apply transformation (DEFAULT: do a dry run)"),
-        )
-        self.parser.add_argument(
-            "--admin",
-            action="store_true",
-            help=_("do a pubsub admin request, needed to change publisher"),
-        )
-        self.parser.add_argument(
-            "-I",
-            "--ignore-errors",
-            action="store_true",
-            help=_(
-                "if command return a non zero exit code, ignore the item and continue"
-            ),
-        )
-        self.parser.add_argument(
-            "-A",
-            "--all",
-            action="store_true",
-            help=_("get all items by looping over all pages using RSM"),
-        )
-        self.parser.add_argument(
-            "command_path",
-            help=_(
-                "path to the command to use. Will be called repetitivly with an "
-                "item as input. Output (full item XML) will be used as new one. "
-                'Return "DELETE" string to delete the item, and "SKIP" to ignore it'
-            ),
-        )
-
-    async def ps_items_send_cb(self, item_ids, metadata):
-        if item_ids:
-            self.disp(
-                _("items published with ids {item_ids}").format(
-                    item_ids=", ".join(item_ids)
-                )
-            )
-        else:
-            self.disp(_("items published"))
-        if self.args.all:
-            return await self.handle_next_page(metadata)
-        else:
-            self.host.quit()
-
-    async def handle_next_page(self, metadata):
-        """Retrieve new page through RSM or quit if we're in the last page
-
-        use to handle --all option
-        @param metadata(dict): metadata as returned by ps_items_get
-        """
-        try:
-            last = metadata["rsm"]["last"]
-            index = int(metadata["rsm"]["index"])
-            count = int(metadata["rsm"]["count"])
-        except KeyError:
-            self.disp(
-                _("Can't retrieve all items, RSM metadata not available"), error=True
-            )
-            self.host.quit(C.EXIT_MISSING_FEATURE)
-        except ValueError as e:
-            self.disp(
-                _("Can't retrieve all items, bad RSM metadata: {msg}").format(msg=e),
-                error=True,
-            )
-            self.host.quit(C.EXIT_ERROR)
-
-        if index + self.args.rsm_max >= count:
-            self.disp(_("All items transformed"))
-            self.host.quit(0)
-
-        self.disp(
-            _("Retrieving next page ({page_idx}/{page_total})").format(
-                page_idx=int(index / self.args.rsm_max) + 1,
-                page_total=int(count / self.args.rsm_max),
-            )
-        )
-
-        extra = self.get_pubsub_extra()
-        extra["rsm_after"] = last
-        try:
-            ps_result = await data_format.deserialise(
-                self.host.bridge.ps_items_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.rsm_max,
-                    self.args.items,
-                    "",
-                    data_format.serialise(extra),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.ps_items_get_cb(ps_result)
-
-    async def ps_items_get_cb(self, ps_result):
-        encoding = "utf-8"
-        new_items = []
-
-        for item in ps_result["items"]:
-            if self.check_duplicates:
-                # this is used when we are not ordering by creation
-                # to avoid infinite loop
-                item_elt, __ = xml_tools.etree_parse(self, item)
-                item_id = item_elt.get("id")
-                if item_id in self.items_ids:
-                    self.disp(
-                        _(
-                            "Duplicate found on item {item_id}, we have probably handled "
-                            "all items."
-                        ).format(item_id=item_id)
-                    )
-                    self.host.quit()
-                self.items_ids.append(item_id)
-
-                # we launch the command to filter the item
-            try:
-                p = await asyncio.create_subprocess_exec(
-                    self.args.command_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE
-                )
-            except OSError as e:
-                exit_code = C.EXIT_CMD_NOT_FOUND if e.errno == 2 else C.EXIT_ERROR
-                self.disp(f"Can't execute the command: {e}", error=True)
-                self.host.quit(exit_code)
-            encoding = "utf-8"
-            cmd_std_out, cmd_std_err = await p.communicate(item.encode(encoding))
-            ret = p.returncode
-            if ret != 0:
-                self.disp(
-                    f"The command returned a non zero status while parsing the "
-                    f"following item:\n\n{item}",
-                    error=True,
-                )
-                if self.args.ignore_errors:
-                    continue
-                else:
-                    self.host.quit(C.EXIT_CMD_ERROR)
-            if cmd_std_err is not None:
-                cmd_std_err = cmd_std_err.decode(encoding, errors="ignore")
-                self.disp(cmd_std_err, error=True)
-            cmd_std_out = cmd_std_out.decode(encoding).strip()
-            if cmd_std_out == "DELETE":
-                item_elt, __ = xml_tools.etree_parse(self, item)
-                item_id = item_elt.get("id")
-                self.disp(_("Deleting item {item_id}").format(item_id=item_id))
-                if self.args.apply:
-                    try:
-                        await self.host.bridge.ps_item_retract(
-                            self.args.service,
-                            self.args.node,
-                            item_id,
-                            False,
-                            self.profile,
-                        )
-                    except Exception as e:
-                        self.disp(f"can't delete item {item_id}: {e}", error=True)
-                        self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                continue
-            elif cmd_std_out == "SKIP":
-                item_elt, __ = xml_tools.etree_parse(self, item)
-                item_id = item_elt.get("id")
-                self.disp(_("Skipping item {item_id}").format(item_id=item_id))
-                continue
-            element, etree = xml_tools.etree_parse(self, cmd_std_out)
-
-            # at this point command has been run and we have a etree.Element object
-            if element.tag not in ("item", "{http://jabber.org/protocol/pubsub}item"):
-                self.disp(
-                    "your script must return a whole item, this is not:\n{xml}".format(
-                        xml=etree.tostring(element, encoding="unicode")
-                    ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_DATA_ERROR)
-
-            if not self.args.apply:
-                # we have a dry run, we just display filtered items
-                serialised = etree.tostring(
-                    element, encoding="unicode", pretty_print=True
-                )
-                self.disp(serialised)
-            else:
-                new_items.append(etree.tostring(element, encoding="unicode"))
-
-        if not self.args.apply:
-            # on dry run we have nothing to wait for, we can quit
-            if self.args.all:
-                return await self.handle_next_page(ps_result)
-            self.host.quit()
-        else:
-            if self.args.admin:
-                bridge_method = self.host.bridge.ps_admin_items_send
-            else:
-                bridge_method = self.host.bridge.ps_items_send
-
-            try:
-                ps_items_send_result = await bridge_method(
-                    self.args.service,
-                    self.args.node,
-                    new_items,
-                    "",
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(f"can't send item: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                await self.ps_items_send_cb(ps_items_send_result, metadata=ps_result)
-
-    async def start(self):
-        if self.args.all and self.args.order_by != C.ORDER_BY_CREATION:
-            self.check_duplicates = True
-            self.items_ids = []
-            self.disp(
-                A.color(
-                    A.FG_RED,
-                    A.BOLD,
-                    '/!\\ "--all" should be used with "--order-by creation" /!\\\n',
-                    A.RESET,
-                    "We'll update items, so order may change during transformation,\n"
-                    "we'll try to mitigate that by stopping on first duplicate,\n"
-                    "but this method is not safe, and some items may be missed.\n---\n",
-                )
-            )
-        else:
-            self.check_duplicates = False
-
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_items_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    "",
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.ps_items_get_cb(ps_result)
-
-
-class Uri(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "uri",
-            use_profile=False,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("build URI"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default=C.PROF_KEY_DEFAULT,
-            help=_("profile (used when no server is specified)"),
-        )
-
-    def display_uri(self, jid_):
-        uri_args = {}
-        if not self.args.service:
-            self.args.service = jid.JID(jid_).bare
-
-        for key in ("node", "service", "item"):
-            value = getattr(self.args, key)
-            if key == "service":
-                key = "path"
-            if value:
-                uri_args[key] = value
-        self.disp(uri.build_xmpp_uri("pubsub", **uri_args))
-        self.host.quit()
-
-    async def start(self):
-        if not self.args.service:
-            try:
-                jid_ = await self.host.bridge.param_get_a_async(
-                    "JabberID", "Connection", profile_key=self.args.profile
-                )
-            except Exception as e:
-                self.disp(f"can't retrieve jid: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                self.display_uri(jid_)
-        else:
-            self.display_uri(None)
-
-
-class AttachmentGet(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
-            help=_("get data attached to an item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            action="append",
-            dest="jids",
-            help=_(
-                "get attached data published only by those JIDs (DEFAULT: get all "
-                "attached data)"
-            )
-        )
-
-    async def start(self):
-        try:
-            attached_data, __ = await self.host.bridge.ps_attachments_get(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.jids or [],
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get attached data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            attached_data = data_format.deserialise(attached_data, type_check=list)
-            await self.output(attached_data)
-            self.host.quit(C.EXIT_OK)
-
-
-class AttachmentSet(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
-            help=_("attach data to an item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--replace",
-            action="store_true",
-            help=_(
-                "replace previous versions of attachments (DEFAULT: update previous "
-                "version)"
-            )
-        )
-        self.parser.add_argument(
-            "-N",
-            "--noticed",
-            metavar="BOOLEAN",
-            nargs="?",
-            default="keep",
-            help=_("mark item as (un)noticed (DEFAULT: keep current value))")
-        )
-        self.parser.add_argument(
-            "-r",
-            "--reactions",
-            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
-            action="append",
-            help=_("emojis to add to react to an item")
-        )
-        self.parser.add_argument(
-            "-R",
-            "--reactions-remove",
-            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
-            action="append",
-            help=_("emojis to remove from reactions to an item")
-        )
-
-    async def start(self):
-        attachments_data = {
-            "service": self.args.service,
-            "node": self.args.node,
-            "id": self.args.item,
-            "extra": {}
-        }
-        operation = "replace" if self.args.replace else "update"
-        if self.args.noticed != "keep":
-            if self.args.noticed is None:
-                self.args.noticed = C.BOOL_TRUE
-            attachments_data["extra"]["noticed"] = C.bool(self.args.noticed)
-
-        if self.args.reactions or self.args.reactions_remove:
-            reactions = attachments_data["extra"]["reactions"] = {
-                "operation": operation
-            }
-            if self.args.replace:
-                reactions["reactions"] = self.args.reactions
-            else:
-                reactions["add"] = self.args.reactions
-                reactions["remove"] = self.args.reactions_remove
-
-
-        if not attachments_data["extra"]:
-            self.parser.error(_("At leat one attachment must be specified."))
-
-        try:
-            await self.host.bridge.ps_attachments_set(
-                data_format.serialise(attachments_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't attach data to item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("data attached")
-            self.host.quit(C.EXIT_OK)
-
-
-class Attachments(base.CommandBase):
-    subcommands = (AttachmentGet, AttachmentSet)
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "attachments",
-            use_profile=False,
-            help=_("set or retrieve items attachments"),
-        )
-
-
-class SignatureSign(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "sign",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("sign an item"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        attachments_data = {
-            "service": self.args.service,
-            "node": self.args.node,
-            "id": self.args.item,
-            "extra": {
-                # we set None to use profile's bare JID
-                "signature": {"signer": None}
-            }
-        }
-        try:
-            await self.host.bridge.ps_attachments_set(
-                data_format.serialise(attachments_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't sign the item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(f"item {self.args.item!r} has been signed")
-            self.host.quit(C.EXIT_OK)
-
-
-class SignatureCheck(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "check",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
-            help=_("check the validity of pubsub signature"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "signature",
-            metavar="JSON",
-            help=_("signature data")
-        )
-
-    async def start(self):
-        try:
-            ret_s = await self.host.bridge.ps_signature_check(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.signature,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't check signature: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(data_format.deserialise((ret_s)))
-            self.host.quit()
-
-
-class Signature(base.CommandBase):
-    subcommands = (
-        SignatureSign,
-        SignatureCheck,
-    )
-
-    def __init__(self, host):
-        super().__init__(
-            host, "signature", use_profile=False, help=_("items signatures")
-        )
-
-
-class SecretShare(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "share",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("share a secret to let other entity encrypt or decrypt items"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k", "--key", metavar="ID", dest="secret_ids", action="append", default=[],
-            help=_(
-                "only share secrets with those IDs (default: share all secrets of the "
-                "node)"
-            )
-        )
-        self.parser.add_argument(
-            "recipient", metavar="JID", help=_("entity who must get the shared secret")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_secret_share(
-                self.args.recipient,
-                self.args.service,
-                self.args.node,
-                self.args.secret_ids,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't share secret: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("secrets have been shared")
-            self.host.quit(C.EXIT_OK)
-
-
-class SecretRevoke(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "revoke",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("revoke an encrypted node secret"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "secret_id", help=_("ID of the secrets to revoke")
-        )
-        self.parser.add_argument(
-            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
-            default=[], help=_(
-                "entity who must get the revocation notification (default: send to all "
-                "entities known to have the shared secret)"
-            )
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_secret_revoke(
-                self.args.service,
-                self.args.node,
-                self.args.secret_id,
-                self.args.recipients,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't revoke secret: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("secret {self.args.secret_id} has been revoked.")
-            self.host.quit(C.EXIT_OK)
-
-
-class SecretRotate(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "rotate",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("revoke existing secrets, create a new one and send notifications"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
-            default=[], help=_(
-                "entity who must get the revocation and shared secret notifications "
-                "(default: send to all entities known to have the shared secret)"
-            )
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_secret_rotate(
-                self.args.service,
-                self.args.node,
-                self.args.recipients,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't rotate secret: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("secret has been rotated")
-            self.host.quit(C.EXIT_OK)
-
-
-class SecretList(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "list",
-            use_pubsub=True,
-            use_verbose=True,
-            pubsub_flags={C.NODE},
-            help=_("list known secrets for a pubsub node"),
-            use_output=C.OUTPUT_LIST_DICT
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            secrets = data_format.deserialise(await self.host.bridge.ps_secrets_list(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            ), type_check=list)
-        except Exception as e:
-            self.disp(f"can't list node secrets: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not self.verbosity:
-                # we don't print key if verbosity is not a least one, to avoid showing it
-                # on the screen accidentally
-                for secret in secrets:
-                    del secret["key"]
-            await self.output(secrets)
-            self.host.quit(C.EXIT_OK)
-
-
-class Secret(base.CommandBase):
-    subcommands = (SecretShare, SecretRevoke, SecretRotate, SecretList)
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "secret",
-            use_profile=False,
-            help=_("handle encrypted nodes secrets"),
-        )
-
-
-class HookCreate(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "create",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("create a Pubsub hook"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            default="python",
-            choices=("python", "python_file", "python_code"),
-            help=_("hook type"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--persistent",
-            action="store_true",
-            help=_("make hook persistent across restarts"),
-        )
-        self.parser.add_argument(
-            "hook_arg",
-            help=_("argument of the hook (depend of the type)"),
-        )
-
-    @staticmethod
-    def check_args(self):
-        if self.args.type == "python_file":
-            self.args.hook_arg = os.path.abspath(self.args.hook_arg)
-            if not os.path.isfile(self.args.hook_arg):
-                self.parser.error(
-                    _("{path} is not a file").format(path=self.args.hook_arg)
-                )
-
-    async def start(self):
-        self.check_args(self)
-        try:
-            await self.host.bridge.ps_hook_add(
-                self.args.service,
-                self.args.node,
-                self.args.type,
-                self.args.hook_arg,
-                self.args.persistent,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create hook: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class HookDelete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("delete a Pubsub hook"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            default="",
-            choices=("", "python", "python_file", "python_code"),
-            help=_("hook type to remove, empty to remove all (DEFAULT: remove all)"),
-        )
-        self.parser.add_argument(
-            "-a",
-            "--arg",
-            dest="hook_arg",
-            default="",
-            help=_(
-                "argument of the hook to remove, empty to remove all (DEFAULT: remove all)"
-            ),
-        )
-
-    async def start(self):
-        HookCreate.check_args(self)
-        try:
-            nb_deleted = await self.host.bridge.ps_hook_remove(
-                self.args.service,
-                self.args.node,
-                self.args.type,
-                self.args.hook_arg,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't delete hook: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _("{nb_deleted} hook(s) have been deleted").format(nb_deleted=nb_deleted)
-            )
-            self.host.quit()
-
-
-class HookList(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "list",
-            use_output=C.OUTPUT_LIST_DICT,
-            help=_("list hooks of a profile"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.ps_hook_list(
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't list hooks: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not data:
-                self.disp(_("No hook found."))
-            await self.output(data)
-            self.host.quit()
-
-
-class Hook(base.CommandBase):
-    subcommands = (HookCreate, HookDelete, HookList)
-
-    def __init__(self, host):
-        super(Hook, self).__init__(
-            host,
-            "hook",
-            use_profile=False,
-            use_verbose=True,
-            help=_("trigger action on Pubsub notifications"),
-        )
-
-
-class Pubsub(base.CommandBase):
-    subcommands = (
-        Set,
-        Get,
-        Delete,
-        Edit,
-        Rename,
-        Subscribe,
-        Unsubscribe,
-        Subscriptions,
-        Affiliations,
-        Reference,
-        Search,
-        Transform,
-        Attachments,
-        Signature,
-        Secret,
-        Hook,
-        Uri,
-        Node,
-        Cache,
-    )
-
-    def __init__(self, host):
-        super(Pubsub, self).__init__(
-            host, "pubsub", use_profile=False, help=_("PubSub nodes/items management")
-        )
--- a/libervia/frontends/jp/cmd_roster.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,327 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2003-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from . import base
-from collections import OrderedDict
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.tools import jid
-from libervia.backend.tools.common.ansi import ANSI as A
-
-__commands__ = ["Roster"]
-
-
-class Get(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host, 'get', use_output=C.OUTPUT_DICT, use_verbose=True,
-            extra_outputs = {"default": self.default_output},
-            help=_('retrieve the roster entities'))
-
-    def add_parser_options(self):
-        pass
-
-    def default_output(self, data):
-        for contact_jid, contact_data in data.items():
-            all_keys = list(contact_data.keys())
-            keys_to_show = []
-            name = contact_data.get('name', contact_jid.node)
-
-            if self.verbosity >= 1:
-                keys_to_show.append('groups')
-                all_keys.remove('groups')
-            if self.verbosity >= 2:
-                keys_to_show.extend(all_keys)
-
-            if name is None:
-                self.disp(A.color(C.A_HEADER, contact_jid))
-            else:
-                self.disp(A.color(C.A_HEADER, name, A.RESET, f" ({contact_jid})"))
-            for k in keys_to_show:
-                value = contact_data[k]
-                if value:
-                    if isinstance(value, list):
-                        value = ', '.join(value)
-                    self.disp(A.color(
-                        "    ", C.A_SUBHEADER, f"{k}: ", A.RESET, str(value)))
-
-    async def start(self):
-        try:
-            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
-        except Exception as e:
-            self.disp(f"error while retrieving the contacts: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        contacts_dict = {}
-        for contact_jid_s, data, groups in contacts:
-            # FIXME: we have to convert string to bool here for historical reason
-            #        contacts_get format should be changed and serialised properly
-            for key in ('from', 'to', 'ask'):
-                if key in data:
-                    data[key] = C.bool(data[key])
-            data['groups'] = list(groups)
-            contacts_dict[jid.JID(contact_jid_s)] = data
-
-        await self.output(contacts_dict)
-        self.host.quit()
-
-
-class Set(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(host, 'set', help=_('set metadata for a roster entity'))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n", "--name", default="", help=_('name to use for this entity'))
-        self.parser.add_argument(
-            "-g", "--group", dest='groups', action='append', metavar='GROUP', default=[],
-            help=_('groups for this entity'))
-        self.parser.add_argument(
-            "-R", "--replace", action="store_true",
-            help=_("replace all metadata instead of adding them"))
-        self.parser.add_argument(
-            "jid", help=_("jid of the roster entity"))
-
-    async def start(self):
-
-        if self.args.replace:
-            name = self.args.name
-            groups = self.args.groups
-        else:
-            try:
-                entity_data = await self.host.bridge.contact_get(
-                    self.args.jid, self.host.profile)
-            except Exception as e:
-                self.disp(f"error while retrieving the contact: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            name = self.args.name or entity_data[0].get('name') or ''
-            groups = set(entity_data[1])
-            groups = list(groups.union(self.args.groups))
-
-        try:
-            await self.host.bridge.contact_update(
-                self.args.jid, name, groups, self.host.profile)
-        except Exception as e:
-            self.disp(f"error while updating the contact: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        self.host.quit()
-
-
-class Delete(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(host, 'delete', help=_('remove an entity from roster'))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true", help=_("delete without confirmation")
-        )
-        self.parser.add_argument(
-            "jid", help=_("jid of the roster entity"))
-
-    async def start(self):
-        if not self.args.force:
-            message = _("Are you sure to delete {entity} from your roster?").format(
-                entity=self.args.jid
-            )
-            await self.host.confirm_or_quit(message, _("entity deletion cancelled"))
-        try:
-            await self.host.bridge.contact_del(
-                self.args.jid, self.host.profile)
-        except Exception as e:
-            self.disp(f"error while deleting the entity: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        self.host.quit()
-
-
-class Stats(base.CommandBase):
-
-    def __init__(self, host):
-        super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
-        except Exception as e:
-            self.disp(f"error while retrieving the contacts: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        hosts = {}
-        unique_groups = set()
-        no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0
-        for contact, attrs, groups in contacts:
-            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
-            if not from_:
-                if not to:
-                    no_sub += 1
-                else:
-                    no_from += 1
-            elif not to:
-                no_to += 1
-
-            host = jid.JID(contact).domain
-
-            hosts.setdefault(host, 0)
-            hosts[host] += 1
-            if groups:
-                unique_groups.update(groups)
-                total_group_subscription += len(groups)
-            if not groups:
-                no_group += 1
-        hosts = OrderedDict(sorted(list(hosts.items()), key=lambda item:-item[1]))
-
-        print()
-        print("Total number of contacts: %d" % len(contacts))
-        print("Number of different hosts: %d" % len(hosts))
-        print()
-        for host, count in hosts.items():
-            print("Contacts on {host}: {count} ({rate:.1f}%)".format(
-                host=host, count=count, rate=100 * float(count) / len(contacts)))
-        print()
-        print("Contacts with no 'from' subscription: %d" % no_from)
-        print("Contacts with no 'to' subscription: %d" % no_to)
-        print("Contacts with no subscription at all: %d" % no_sub)
-        print()
-        print("Total number of groups: %d" % len(unique_groups))
-        try:
-            contacts_per_group = float(total_group_subscription) / len(unique_groups)
-        except ZeroDivisionError:
-            contacts_per_group = 0
-        print("Average contacts per group: {:.1f}".format(contacts_per_group))
-        try:
-            groups_per_contact = float(total_group_subscription) / len(contacts)
-        except ZeroDivisionError:
-            groups_per_contact = 0
-        print(f"Average groups' subscriptions per contact: {groups_per_contact:.1f}")
-        print("Contacts not assigned to any group: %d" % no_group)
-        self.host.quit()
-
-
-class Purge(base.CommandBase):
-
-    def __init__(self, host):
-        super(Purge, self).__init__(
-            host, 'purge',
-            help=_('purge the roster from its contacts with no subscription'))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--no-from", action="store_true",
-            help=_("also purge contacts with no 'from' subscription"))
-        self.parser.add_argument(
-            "--no-to", action="store_true",
-            help=_("also purge contacts with no 'to' subscription"))
-
-    async def start(self):
-        try:
-            contacts = await self.host.bridge.contacts_get(self.host.profile)
-        except Exception as e:
-            self.disp(f"error while retrieving the contacts: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        no_sub, no_from, no_to = [], [], []
-        for contact, attrs, groups in contacts:
-            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
-            if not from_:
-                if not to:
-                    no_sub.append(contact)
-                elif self.args.no_from:
-                    no_from.append(contact)
-            elif not to and self.args.no_to:
-                no_to.append(contact)
-        if not no_sub and not no_from and not no_to:
-            self.disp(
-                f"Nothing to do - there's a from and/or to subscription(s) between "
-                f"profile {self.host.profile!r} and each of its contacts"
-            )
-        elif await self.ask_confirmation(no_sub, no_from, no_to):
-            for contact in no_sub + no_from + no_to:
-                try:
-                    await self.host.bridge.contact_del(
-                        contact, profile_key=self.host.profile)
-                except Exception as e:
-                    self.disp(f"can't delete contact {contact!r}: {e}", error=True)
-                else:
-                    self.disp(f"contact {contact!r} has been removed")
-
-        self.host.quit()
-
-    async def ask_confirmation(self, no_sub, no_from, no_to):
-        """Ask the confirmation before removing contacts.
-
-        @param no_sub (list[unicode]): list of contacts with no subscription
-        @param no_from (list[unicode]): list of contacts with no 'from' subscription
-        @param no_to (list[unicode]): list of contacts with no 'to' subscription
-        @return bool
-        """
-        if no_sub:
-            self.disp(
-                f"There's no subscription between profile {self.host.profile!r} and the "
-                f"following contacts:")
-            self.disp("    " + "\n    ".join(no_sub))
-        if no_from:
-            self.disp(
-                f"There's no 'from' subscription between profile {self.host.profile!r} "
-                f"and the following contacts:")
-            self.disp("    " + "\n    ".join(no_from))
-        if no_to:
-            self.disp(
-                f"There's no 'to' subscription between profile {self.host.profile!r} and "
-                f"the following contacts:")
-            self.disp("    " + "\n    ".join(no_to))
-        message = f"REMOVE them from profile {self.host.profile}'s roster"
-        while True:
-            res = await self.host.ainput(f"{message} (y/N)? ")
-            if not res or res.lower() == 'n':
-                return False
-            if res.lower() == 'y':
-                return True
-
-
-class Resync(base.CommandBase):
-
-    def __init__(self, host):
-        super(Resync, self).__init__(
-            host, 'resync', help=_('do a full resynchronisation of roster with server'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.roster_resync(profile_key=self.host.profile)
-        except Exception as e:
-            self.disp(f"can't resynchronise roster: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("Roster resynchronized"))
-            self.host.quit(C.EXIT_OK)
-
-
-class Roster(base.CommandBase):
-    subcommands = (Get, Set, Delete, Stats, Purge, Resync)
-
-    def __init__(self, host):
-        super(Roster, self).__init__(
-            host, 'roster', use_profile=True, help=_("Manage an entity's roster"))
--- a/libervia/frontends/jp/cmd_shell.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,305 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import cmd
-import sys
-import shlex
-import subprocess
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import arg_tools
-from libervia.backend.tools.common.ansi import ANSI as A
-
-__commands__ = ["Shell"]
-INTRO = _(
-    """Welcome to {app_name} shell, the Salut à Toi shell !
-
-This enrironment helps you using several {app_name} commands with similar parameters.
-
-To quit, just enter "quit" or press C-d.
-Enter "help" or "?" to know what to do
-"""
-).format(app_name=C.APP_NAME)
-
-
-class Shell(base.CommandBase, cmd.Cmd):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "shell",
-            help=_("launch jp in shell (REPL) mode")
-        )
-        cmd.Cmd.__init__(self)
-
-    def parse_args(self, args):
-        """parse line arguments"""
-        return shlex.split(args, posix=True)
-
-    def update_path(self):
-        self._cur_parser = self.host.parser
-        self.help = ""
-        for idx, path_elt in enumerate(self.path):
-            try:
-                self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser)
-            except exceptions.NotFound:
-                self.disp(_("bad command path"), error=True)
-                self.path = self.path[:idx]
-                break
-            else:
-                self.help = self._cur_parser
-
-        self.prompt = A.color(C.A_PROMPT_PATH, "/".join(self.path)) + A.color(
-            C.A_PROMPT_SUF, "> "
-        )
-        try:
-            self.actions = list(arg_tools.get_cmd_choices(parser=self._cur_parser).keys())
-        except exceptions.NotFound:
-            self.actions = []
-
-    def add_parser_options(self):
-        pass
-
-    def format_args(self, args):
-        """format argument to be printed with quotes if needed"""
-        for arg in args:
-            if " " in arg:
-                yield arg_tools.escape(arg)
-            else:
-                yield arg
-
-    def run_cmd(self, args, external=False):
-        """run command and retur exit code
-
-        @param args[list[string]]: arguments of the command
-            must not include program name
-        @param external(bool): True if it's an external command (i.e. not jp)
-        @return (int): exit code (0 success, any other int failure)
-        """
-        # FIXME: we have to use subprocess
-        # and relaunch whole python for now
-        # because if host.quit() is called in D-Bus callback
-        # GLib quit the whole app without possibility to stop it
-        # didn't found a nice way to work around it so far
-        # Situation should be better when we'll move away from python-dbus
-        if self.verbose:
-            self.disp(
-                _("COMMAND {external}=> {args}").format(
-                    external=_("(external) ") if external else "",
-                    args=" ".join(self.format_args(args)),
-                )
-            )
-        if not external:
-            args = sys.argv[0:1] + args
-        ret_code = subprocess.call(args)
-        # XXX: below is a way to launch the command without creating a new process
-        #      may be used when a solution to the aforementioned issue is there
-        # try:
-        #     self.host._run(args)
-        # except SystemExit as e:
-        #     ret_code = e.code
-        # except Exception as e:
-        #     self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True)
-        #     ret_code = 1
-        # else:
-        #     ret_code = 0
-
-        if ret_code != 0:
-            self.disp(
-                A.color(
-                    C.A_FAILURE,
-                    "command failed with an error code of {err_no}".format(
-                        err_no=ret_code
-                    ),
-                ),
-                error=True,
-            )
-        return ret_code
-
-    def default(self, args):
-        """called when no shell command is recognized
-
-        will launch the command with args on the line
-        (i.e. will launch do [args])
-        """
-        if args == "EOF":
-            self.do_quit("")
-        self.do_do(args)
-
-    def do_help(self, args):
-        """show help message"""
-        if not args:
-            self.disp(A.color(C.A_HEADER, _("Shell commands:")), end=' ')
-        super(Shell, self).do_help(args)
-        if not args:
-            self.disp(A.color(C.A_HEADER, _("Action commands:")))
-            help_list = self._cur_parser.format_help().split("\n\n")
-            print(("\n\n".join(help_list[1 if self.path else 2 :])))
-
-    # FIXME: debug crashes on exit and is not that useful,
-    #        keeping it until refactoring, may be removed entirely then
-    # def do_debug(self, args):
-    #     """launch internal debugger"""
-    #     try:
-    #         import ipdb as pdb
-    #     except ImportError:
-    #         import pdb
-    #     pdb.set_trace()
-
-    def do_verbose(self, args):
-        """show verbose mode, or (de)activate it"""
-        args = self.parse_args(args)
-        if args:
-            self.verbose = C.bool(args[0])
-        self.disp(
-            _("verbose mode is {status}").format(
-                status=_("ENABLED") if self.verbose else _("DISABLED")
-            )
-        )
-
-    def do_cmd(self, args):
-        """change command path"""
-        if args == "..":
-            self.path = self.path[:-1]
-        else:
-            if not args or args[0] == "/":
-                self.path = []
-            args = "/".join(args.split())
-            for path_elt in args.split("/"):
-                path_elt = path_elt.strip()
-                if not path_elt:
-                    continue
-                self.path.append(path_elt)
-        self.update_path()
-
-    def do_version(self, args):
-        """show current SàT/jp version"""
-        self.run_cmd(['--version'])
-
-    def do_shell(self, args):
-        """launch an external command (you can use ![command] too)"""
-        args = self.parse_args(args)
-        self.run_cmd(args, external=True)
-
-    def do_do(self, args):
-        """lauch a command"""
-        args = self.parse_args(args)
-        if (
-            self._not_default_profile
-            and not "-p" in args
-            and not "--profile" in args
-            and not "profile" in self.use
-        ):
-            # profile is not specified and we are not using the default profile
-            # so we need to add it in arguments to use current user profile
-            if self.verbose:
-                self.disp(
-                    _("arg profile={profile} (logged profile)").format(
-                        profile=self.profile
-                    )
-                )
-            use = self.use.copy()
-            use["profile"] = self.profile
-        else:
-            use = self.use
-
-        # args may be modified by use_args
-        # to remove subparsers from it
-        parser_args, use_args = arg_tools.get_use_args(
-            self.host, args, use, verbose=self.verbose, parser=self._cur_parser
-        )
-        cmd_args = self.path + parser_args + use_args
-        self.run_cmd(cmd_args)
-
-    def do_use(self, args):
-        """fix an argument"""
-        args = self.parse_args(args)
-        if not args:
-            if not self.use:
-                self.disp(_("no argument in USE"))
-            else:
-                self.disp(_("arguments in USE:"))
-                for arg, value in self.use.items():
-                    self.disp(
-                        _(
-                            A.color(
-                                C.A_SUBHEADER,
-                                arg,
-                                A.RESET,
-                                " = ",
-                                arg_tools.escape(value),
-                            )
-                        )
-                    )
-        elif len(args) != 2:
-            self.disp("bad syntax, please use:\nuse [arg] [value]", error=True)
-        else:
-            self.use[args[0]] = " ".join(args[1:])
-            if self.verbose:
-                self.disp(
-                    "set {name} = {value}".format(
-                        name=args[0], value=arg_tools.escape(args[1])
-                    )
-                )
-
-    def do_use_clear(self, args):
-        """unset one or many argument(s) in USE, or all of them if no arg is specified"""
-        args = self.parse_args(args)
-        if not args:
-            self.use.clear()
-        else:
-            for arg in args:
-                try:
-                    del self.use[arg]
-                except KeyError:
-                    self.disp(
-                        A.color(
-                            C.A_FAILURE, _("argument {name} not found").format(name=arg)
-                        ),
-                        error=True,
-                    )
-                else:
-                    if self.verbose:
-                        self.disp(_("argument {name} removed").format(name=arg))
-
-    def do_whoami(self, args):
-        """print profile currently used"""
-        self.disp(self.profile)
-
-    def do_quit(self, args):
-        """quit the shell"""
-        self.disp(_("good bye!"))
-        self.host.quit()
-
-    def do_exit(self, args):
-        """alias for quit"""
-        self.do_quit(args)
-
-    async def start(self):
-        # FIXME: "shell" is currently kept synchronous as it works well as it
-        #        and it will be refactored soon.
-        default_profile = self.host.bridge.profile_name_get(C.PROF_KEY_DEFAULT)
-        self._not_default_profile = self.profile != default_profile
-        self.path = []
-        self._cur_parser = self.host.parser
-        self.use = {}
-        self.verbose = False
-        self.update_path()
-        self.cmdloop(INTRO)
--- a/libervia/frontends/jp/cmd_uri.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,81 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools.common import uri
-
-__commands__ = ["Uri"]
-
-
-class Parse(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "parse",
-            use_profile=False,
-            use_output=C.OUTPUT_DICT,
-            help=_("parse URI"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "uri", help=_("XMPP URI to parse")
-        )
-
-    async def start(self):
-        await self.output(uri.parse_xmpp_uri(self.args.uri))
-        self.host.quit()
-
-
-class Build(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "build", use_profile=False, help=_("build URI")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("type", help=_("URI type"))
-        self.parser.add_argument("path", help=_("URI path"))
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            metavar=("KEY", "VALUE"),
-            help=_("URI fields"),
-        )
-
-    async def start(self):
-        fields = dict(self.args.fields) if self.args.fields else {}
-        self.disp(uri.build_xmpp_uri(self.args.type, path=self.args.path, **fields))
-        self.host.quit()
-
-
-class Uri(base.CommandBase):
-    subcommands = (Parse, Build)
-
-    def __init__(self, host):
-        super(Uri, self).__init__(
-            host, "uri", use_profile=False, help=_("XMPP URI parsing/generation")
-        )
--- a/libervia/frontends/jp/common.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,833 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import json
-import os
-import os.path
-import time
-import tempfile
-import asyncio
-import shlex
-import re
-from pathlib import Path
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from libervia.backend.tools.common import regex
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import uri as xmpp_uri
-from libervia.backend.tools import config
-from configparser import NoSectionError, NoOptionError
-from collections import namedtuple
-
-# default arguments used for some known editors (editing with metadata)
-VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'"
-EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"'
-EDITOR_ARGS_MAGIC = {
-    "vim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "nvim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "gvim": VIM_SPLIT_ARGS + " --nofork {content_file} {metadata_file}",
-    "emacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "xemacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "nano": " -F {content_file} {metadata_file}",
-}
-
-SECURE_UNLINK_MAX = 10
-SECURE_UNLINK_DIR = ".backup"
-METADATA_SUFF = "_metadata.json"
-
-
-def format_time(timestamp):
-    """Return formatted date for timestamp
-
-    @param timestamp(str,int,float): unix timestamp
-    @return (unicode): formatted date
-    """
-    fmt = "%d/%m/%Y %H:%M:%S %Z"
-    return time.strftime(fmt, time.localtime(float(timestamp)))
-
-
-def ansi_ljust(s, width):
-    """ljust method handling ANSI escape codes"""
-    cleaned = regex.ansi_remove(s)
-    return s + " " * (width - len(cleaned))
-
-
-def ansi_center(s, width):
-    """ljust method handling ANSI escape codes"""
-    cleaned = regex.ansi_remove(s)
-    diff = width - len(cleaned)
-    half = diff / 2
-    return half * " " + s + (half + diff % 2) * " "
-
-
-def ansi_rjust(s, width):
-    """ljust method handling ANSI escape codes"""
-    cleaned = regex.ansi_remove(s)
-    return " " * (width - len(cleaned)) + s
-
-
-def get_tmp_dir(sat_conf, cat_dir, sub_dir=None):
-    """Return directory used to store temporary files
-
-    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
-    @param cat_dir(str): directory of the category (e.g. "blog")
-    @param sub_dir(str): sub directory where data need to be put
-        profile can be used here, or special directory name
-        sub_dir will be escaped to be usable in path (use regex.path_unescape to find
-        initial str)
-    @return (Path): path to the dir
-    """
-    local_dir = config.config_get(sat_conf, "", "local_dir", Exception)
-    path_elts = [local_dir, cat_dir]
-    if sub_dir is not None:
-        path_elts.append(regex.path_escape(sub_dir))
-    return Path(*path_elts)
-
-
-def parse_args(host, cmd_line, **format_kw):
-    """Parse command arguments
-
-    @param cmd_line(unicode): command line as found in sat.conf
-    @param format_kw: keywords used for formating
-    @return (list(unicode)): list of arguments to pass to subprocess function
-    """
-    try:
-        # we split the arguments and add the known fields
-        # we split arguments first to avoid escaping issues in file names
-        return [a.format(**format_kw) for a in shlex.split(cmd_line)]
-    except ValueError as e:
-        host.disp(
-            "Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e)
-        )
-        return []
-
-
-class BaseEdit(object):
-    """base class for editing commands
-
-    This class allows to edit file for PubSub or something else.
-    It works with temporary files in SàT local_dir, in a "cat_dir" subdir
-    """
-
-    def __init__(self, host, cat_dir, use_metadata=False):
-        """
-        @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
-        @param cat_dir(unicode): directory to use for drafts
-            this will be a sub-directory of SàT's local_dir
-        @param use_metadata(bool): True is edition need a second file for metadata
-            most of signature change with use_metadata with an additional metadata
-            argument.
-            This is done to raise error if a command needs metadata but forget the flag,
-            and vice versa
-        """
-        self.host = host
-        self.cat_dir = cat_dir
-        self.use_metadata = use_metadata
-
-    def secure_unlink(self, path):
-        """Unlink given path after keeping it for a while
-
-        This method is used to prevent accidental deletion of a draft
-        If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
-        older file are deleted
-        @param path(Path, str): file to unlink
-        """
-        path = Path(path).resolve()
-        if not path.is_file:
-            raise OSError("path must link to a regular file")
-        if path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
-            self.disp(
-                f"File {path} is not in SàT temporary hierarchy, we do not remove " f"it",
-                2,
-            )
-            return
-            # we have 2 files per draft with use_metadata, so we double max
-        unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX
-        backup_dir = get_tmp_dir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR)
-        if not os.path.exists(backup_dir):
-            os.makedirs(backup_dir)
-        filename = os.path.basename(path)
-        backup_path = os.path.join(backup_dir, filename)
-        # we move file to backup dir
-        self.host.disp(
-            "Backuping file {src} to {dst}".format(src=path, dst=backup_path),
-            1,
-        )
-        os.rename(path, backup_path)
-        # and if we exceeded the limit, we remove older file
-        backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
-        if len(backup_files) > unlink_max:
-            backup_files.sort(key=lambda path: os.stat(path).st_mtime)
-            for path in backup_files[: len(backup_files) - unlink_max]:
-                self.host.disp("Purging backup file {}".format(path), 2)
-                os.unlink(path)
-
-    async def run_editor(
-        self,
-        editor_args_opt,
-        content_file_path,
-        content_file_obj,
-        meta_file_path=None,
-        meta_ori=None,
-    ):
-        """Run editor to edit content and metadata
-
-        @param editor_args_opt(unicode): option in [jp] section in configuration for
-            specific args
-        @param content_file_path(str): path to the content file
-        @param content_file_obj(file): opened file instance
-        @param meta_file_path(str, Path, None): metadata file path
-            if None metadata will not be used
-        @param meta_ori(dict, None): original cotent of metadata
-            can't be used if use_metadata is False
-        """
-        if not self.use_metadata:
-            assert meta_file_path is None
-            assert meta_ori is None
-
-            # we calculate hashes to check for modifications
-        import hashlib
-
-        content_file_obj.seek(0)
-        tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
-        content_file_obj.close()
-
-        # we prepare arguments
-        editor = config.config_get(self.sat_conf, C.CONFIG_SECTION, "editor") or os.getenv(
-            "EDITOR", "vi"
-        )
-        try:
-            # is there custom arguments in sat.conf ?
-            editor_args = config.config_get(
-                self.sat_conf, C.CONFIG_SECTION, editor_args_opt, Exception
-            )
-        except (NoOptionError, NoSectionError):
-            # no, we check if we know the editor and have special arguments
-            if self.use_metadata:
-                editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), "")
-            else:
-                editor_args = ""
-        parse_kwargs = {"content_file": content_file_path}
-        if self.use_metadata:
-            parse_kwargs["metadata_file"] = meta_file_path
-        args = parse_args(self.host, editor_args, **parse_kwargs)
-        if not args:
-            args = [content_file_path]
-
-            # actual editing
-        editor_process = await asyncio.create_subprocess_exec(
-            editor, *[str(a) for a in args]
-        )
-        editor_exit = await editor_process.wait()
-
-        # edition will now be checked, and data will be sent if it was a success
-        if editor_exit != 0:
-            self.disp(
-                f"Editor exited with an error code, so temporary file has not be "
-                f"deleted, and item is not published.\nYou can find temporary file "
-                f"at {content_file_path}",
-                error=True,
-            )
-        else:
-            # main content
-            try:
-                with content_file_path.open("rb") as f:
-                    content = f.read()
-            except (OSError, IOError):
-                self.disp(
-                    f"Can read file at {content_file_path}, have it been deleted?\n"
-                    f"Cancelling edition",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-
-                # metadata
-            if self.use_metadata:
-                try:
-                    with meta_file_path.open("rb") as f:
-                        metadata = json.load(f)
-                except (OSError, IOError):
-                    self.disp(
-                        f"Can read file at {meta_file_path}, have it been deleted?\n"
-                        f"Cancelling edition",
-                        error=True,
-                    )
-                    self.host.quit(C.EXIT_NOT_FOUND)
-                except ValueError:
-                    self.disp(
-                        f"Can't parse metadata, please check it is correct JSON format. "
-                        f"Cancelling edition.\nYou can find tmp file at "
-                        f"{content_file_path} and temporary meta file at "
-                        f"{meta_file_path}.",
-                        error=True,
-                    )
-                    self.host.quit(C.EXIT_DATA_ERROR)
-
-            if self.use_metadata and not metadata.get("publish", True):
-                self.disp(
-                    f'Publication blocked by "publish" key in metadata, cancelling '
-                    f"edition.\n\ntemporary file path:\t{content_file_path}\nmetadata "
-                    f"file path:\t{meta_file_path}",
-                    error=True,
-                )
-                self.host.quit()
-
-            if len(content) == 0:
-                self.disp("Content is empty, cancelling the edition")
-                if content_file_path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
-                    self.disp(
-                        "File are not in SàT temporary hierarchy, we do not remove them",
-                        2,
-                    )
-                    self.host.quit()
-                self.disp(f"Deletion of {content_file_path}", 2)
-                os.unlink(content_file_path)
-                if self.use_metadata:
-                    self.disp(f"Deletion of {meta_file_path}".format(meta_file_path), 2)
-                    os.unlink(meta_file_path)
-                self.host.quit()
-
-                # time to re-check the hash
-            elif tmp_ori_hash == hashlib.sha1(content).digest() and (
-                not self.use_metadata or meta_ori == metadata
-            ):
-                self.disp("The content has not been modified, cancelling the edition")
-                self.host.quit()
-
-            else:
-                # we can now send the item
-                content = content.decode("utf-8-sig")  # we use utf-8-sig to avoid BOM
-                try:
-                    if self.use_metadata:
-                        await self.publish(content, metadata)
-                    else:
-                        await self.publish(content)
-                except Exception as e:
-                    if self.use_metadata:
-                        self.disp(
-                            f"Error while sending your item, the temporary files have "
-                            f"been kept at {content_file_path} and {meta_file_path}: "
-                            f"{e}",
-                            error=True,
-                        )
-                    else:
-                        self.disp(
-                            f"Error while sending your item, the temporary file has been "
-                            f"kept at {content_file_path}: {e}",
-                            error=True,
-                        )
-                    self.host.quit(1)
-
-            self.secure_unlink(content_file_path)
-            if self.use_metadata:
-                self.secure_unlink(meta_file_path)
-
-    async def publish(self, content):
-        # if metadata is needed, publish will be called with it last argument
-        raise NotImplementedError
-
-    def get_tmp_file(self):
-        """Create a temporary file
-
-        @return (tuple(file, Path)): opened (w+b) file object and file path
-        """
-        suff = "." + self.get_tmp_suff()
-        cat_dir_str = self.cat_dir
-        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, self.profile)
-        if not tmp_dir.exists():
-            try:
-                tmp_dir.mkdir(parents=True)
-            except OSError as e:
-                self.disp(
-                    f"Can't create {tmp_dir} directory: {e}",
-                    error=True,
-                )
-                self.host.quit(1)
-        try:
-            fd, path = tempfile.mkstemp(
-                suffix=suff,
-                prefix=time.strftime(cat_dir_str + "_%Y-%m-%d_%H:%M:%S_"),
-                dir=tmp_dir,
-                text=True,
-            )
-            return os.fdopen(fd, "w+b"), Path(path)
-        except OSError as e:
-            self.disp(f"Can't create temporary file: {e}", error=True)
-            self.host.quit(1)
-
-    def get_current_file(self, profile):
-        """Get most recently edited file
-
-        @param profile(unicode): profile linked to the draft
-        @return(Path): full path of current file
-        """
-        # we guess the item currently edited by choosing
-        # the most recent file corresponding to temp file pattern
-        # in tmp_dir, excluding metadata files
-        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, profile)
-        available = [
-            p
-            for p in tmp_dir.glob(f"{self.cat_dir}_*")
-            if not p.match(f"*{METADATA_SUFF}")
-        ]
-        if not available:
-            self.disp(
-                f"Could not find any content draft in {tmp_dir}",
-                error=True,
-            )
-            self.host.quit(1)
-        return max(available, key=lambda p: p.stat().st_mtime)
-
-    async def get_item_data(self, service, node, item):
-        """return formatted content, metadata (or not if use_metadata is false), and item id"""
-        raise NotImplementedError
-
-    def get_tmp_suff(self):
-        """return suffix used for content file"""
-        return "xml"
-
-    async def get_item_path(self):
-        """Retrieve item path (i.e. service and node) from item argument
-
-        This method is obviously only useful for edition of PubSub based features
-        """
-        service = self.args.service
-        node = self.args.node
-        item = self.args.item
-        last_item = self.args.last_item
-
-        if self.args.current:
-            # user wants to continue current draft
-            content_file_path = self.get_current_file(self.profile)
-            self.disp("Continuing edition of current draft", 2)
-            content_file_obj = content_file_path.open("r+b")
-            # we seek at the end of file in case of an item already exist
-            # this will write content of the existing item at the end of the draft.
-            # This way no data should be lost.
-            content_file_obj.seek(0, os.SEEK_END)
-        elif self.args.draft_path:
-            # there is an existing draft that we use
-            content_file_path = self.args.draft_path.expanduser()
-            content_file_obj = content_file_path.open("r+b")
-            # we seek at the end for the same reason as above
-            content_file_obj.seek(0, os.SEEK_END)
-        else:
-            # we need a temporary file
-            content_file_obj, content_file_path = self.get_tmp_file()
-
-        if item or last_item:
-            self.disp("Editing requested published item", 2)
-            try:
-                if self.use_metadata:
-                    content, metadata, item = await self.get_item_data(service, node, item)
-                else:
-                    content, item = await self.get_item_data(service, node, item)
-            except Exception as e:
-                # FIXME: ugly but we have not good may to check errors in bridge
-                if "item-not-found" in str(e):
-                    #  item doesn't exist, we create a new one with requested id
-                    metadata = None
-                    if last_item:
-                        self.disp(_("no item found at all, we create a new one"), 2)
-                    else:
-                        self.disp(
-                            _(
-                                'item "{item}" not found, we create a new item with'
-                                "this id"
-                            ).format(item=item),
-                            2,
-                        )
-                    content_file_obj.seek(0)
-                else:
-                    self.disp(f"Error while retrieving item: {e}")
-                    self.host.quit(C.EXIT_ERROR)
-            else:
-                # item exists, we write content
-                if content_file_obj.tell() != 0:
-                    # we already have a draft,
-                    # we copy item content after it and add an indicator
-                    content_file_obj.write("\n*****\n")
-                content_file_obj.write(content.encode("utf-8"))
-                content_file_obj.seek(0)
-                self.disp(_('item "{item}" found, we edit it').format(item=item), 2)
-        else:
-            self.disp("Editing a new item", 2)
-            if self.use_metadata:
-                metadata = None
-
-        if self.use_metadata:
-            return service, node, item, content_file_path, content_file_obj, metadata
-        else:
-            return service, node, item, content_file_path, content_file_obj
-
-
-class Table(object):
-    def __init__(self, host, data, headers=None, filters=None, use_buffer=False):
-        """
-        @param data(iterable[list]): table data
-            all lines must have the same number of columns
-        @param headers(iterable[unicode], None): names/titles of the columns
-            if not None, must have same number of columns as data
-        @param filters(iterable[(callable, unicode)], None): values filters
-            the callable will get 2 arguments:
-                - current column value
-                - RowData with all columns values
-            if may also only use 1 argument, which will then be current col value.
-            the callable must return a string
-            if it's unicode, it will be used with .format and must countain u'{}' which
-            will be replaced with the string.
-            if not None, must have same number of columns as data
-        @param use_buffer(bool): if True, bufferise output instead of printing it directly
-        """
-        self.host = host
-        self._buffer = [] if use_buffer else None
-        #  headers are columns names/titles, can be None
-        self.headers = headers
-        #  sizes fof columns without headers,
-        # headers may be larger
-        self.sizes = []
-        #  rows countains one list per row with columns values
-        self.rows = []
-
-        size = None
-        if headers:
-            # we use a namedtuple to make the value easily accessible from filters
-            headers_safe = [re.sub(r"[^a-zA-Z_]", "_", h) for h in headers]
-            row_cls = namedtuple("RowData", headers_safe)
-        else:
-            row_cls = tuple
-
-        for row_data in data:
-            new_row = []
-            row_data_list = list(row_data)
-            for idx, value in enumerate(row_data_list):
-                if filters is not None and filters[idx] is not None:
-                    filter_ = filters[idx]
-                    if isinstance(filter_, str):
-                        col_value = filter_.format(value)
-                    else:
-                        try:
-                            col_value = filter_(value, row_cls(*row_data_list))
-                        except TypeError:
-                            col_value = filter_(value)
-                            # we count size without ANSI code as they will change length of the
-                            # string when it's mostly style/color changes.
-                    col_size = len(regex.ansi_remove(col_value))
-                else:
-                    col_value = str(value)
-                    col_size = len(col_value)
-                new_row.append(col_value)
-                if size is None:
-                    self.sizes.append(col_size)
-                else:
-                    self.sizes[idx] = max(self.sizes[idx], col_size)
-            if size is None:
-                size = len(new_row)
-                if headers is not None and len(headers) != size:
-                    raise exceptions.DataError("headers size is not coherent with rows")
-            else:
-                if len(new_row) != size:
-                    raise exceptions.DataError("rows size is not coherent")
-            self.rows.append(new_row)
-
-        if not data and headers is not None:
-            #  the table is empty, we print headers at their lenght
-            self.sizes = [len(h) for h in headers]
-
-    @property
-    def string(self):
-        if self._buffer is None:
-            raise exceptions.InternalError("buffer must be used to get a string")
-        return "\n".join(self._buffer)
-
-    @staticmethod
-    def read_dict_values(data, keys, defaults=None):
-        if defaults is None:
-            defaults = {}
-        for key in keys:
-            try:
-                yield data[key]
-            except KeyError as e:
-                default = defaults.get(key)
-                if default is not None:
-                    yield default
-                else:
-                    raise e
-
-    @classmethod
-    def from_list_dict(
-        cls, host, data, keys=None, headers=None, filters=None, defaults=None
-    ):
-        """Create a table from a list of dictionaries
-
-        each dictionary is a row of the table, keys being columns names.
-        the whole data will be read and kept into memory, to be printed
-        @param data(list[dict[unicode, unicode]]): data to create the table from
-        @param keys(iterable[unicode], None): keys to get
-            if None, all keys will be used
-        @param headers(iterable[unicode], None): name of the columns
-            names must be in same order as keys
-        @param filters(dict[unicode, (callable,unicode)), None): filter to use on values
-            keys correspond to keys to filter, and value is the same as for Table.__init__
-        @param defaults(dict[unicode, unicode]): default value to use
-            if None, an exception will be raised if not value is found
-        """
-        if keys is None and headers is not None:
-            # FIXME: keys are not needed with OrderedDict,
-            raise exceptions.DataError("You must specify keys order to used headers")
-        if keys is None:
-            keys = list(data[0].keys())
-        if headers is None:
-            headers = keys
-        if filters is None:
-            filters = {}
-        filters = [filters.get(k) for k in keys]
-        return cls(
-            host, (cls.read_dict_values(d, keys, defaults) for d in data), headers, filters
-        )
-
-    def _headers(self, head_sep, headers, sizes, alignment="left", style=None):
-        """Render headers
-
-        @param head_sep(unicode): sequence to use as separator
-        @param alignment(unicode): how to align, can be left, center or right
-        @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply
-        @param headers(list[unicode]): headers to show
-        @param sizes(list[int]): sizes of columns
-        """
-        rendered_headers = []
-        if isinstance(style, str):
-            style = [style]
-        for idx, header in enumerate(headers):
-            size = sizes[idx]
-            if alignment == "left":
-                rendered = header[:size].ljust(size)
-            elif alignment == "center":
-                rendered = header[:size].center(size)
-            elif alignment == "right":
-                rendered = header[:size].rjust(size)
-            else:
-                raise exceptions.InternalError("bad alignment argument")
-            if style:
-                args = style + [rendered]
-                rendered = A.color(*args)
-            rendered_headers.append(rendered)
-        return head_sep.join(rendered_headers)
-
-    def _disp(self, data):
-        """output data (can be either bufferised or printed)"""
-        if self._buffer is not None:
-            self._buffer.append(data)
-        else:
-            self.host.disp(data)
-
-    def display(
-        self,
-        head_alignment="left",
-        columns_alignment="left",
-        head_style=None,
-        show_header=True,
-        show_borders=True,
-        hide_cols=None,
-        col_sep=" │ ",
-        top_left="┌",
-        top="─",
-        top_sep="─┬─",
-        top_right="┐",
-        left="│",
-        right=None,
-        head_sep=None,
-        head_line="┄",
-        head_line_left="├",
-        head_line_sep="┄┼┄",
-        head_line_right="┤",
-        bottom_left="└",
-        bottom=None,
-        bottom_sep="─┴─",
-        bottom_right="┘",
-    ):
-        """Print the table
-
-        @param show_header(bool): True if header need no be shown
-        @param show_borders(bool): True if borders need no be shown
-        @param hide_cols(None, iterable(unicode)): columns which should not be displayed
-        @param head_alignment(unicode): how to align headers, can be left, center or right
-        @param columns_alignment(unicode): how to align columns, can be left, center or
-            right
-        @param col_sep(unicode): separator betweens columns
-        @param head_line(unicode): character to use to make line under head
-        @param disp(callable, None): method to use to display the table
-            None to use self.host.disp
-        """
-        if not self.sizes:
-            # the table is empty
-            return
-        col_sep_size = len(regex.ansi_remove(col_sep))
-
-        # if we have columns to hide, we remove them from headers and size
-        if not hide_cols:
-            headers = self.headers
-            sizes = self.sizes
-        else:
-            headers = list(self.headers)
-            sizes = self.sizes[:]
-            ignore_idx = [headers.index(to_hide) for to_hide in hide_cols]
-            for to_hide in hide_cols:
-                hide_idx = headers.index(to_hide)
-                del headers[hide_idx]
-                del sizes[hide_idx]
-
-        if right is None:
-            right = left
-        if top_sep is None:
-            top_sep = col_sep_size * top
-        if head_sep is None:
-            head_sep = col_sep
-        if bottom is None:
-            bottom = top
-        if bottom_sep is None:
-            bottom_sep = col_sep_size * bottom
-        if not show_borders:
-            left = right = head_line_left = head_line_right = ""
-            # top border
-        if show_borders:
-            self._disp(
-                top_left + top_sep.join([top * size for size in sizes]) + top_right
-            )
-
-            # headers
-        if show_header and self.headers is not None:
-            self._disp(
-                left
-                + self._headers(head_sep, headers, sizes, head_alignment, head_style)
-                + right
-            )
-            # header line
-            self._disp(
-                head_line_left
-                + head_line_sep.join([head_line * size for size in sizes])
-                + head_line_right
-            )
-
-            # content
-        if columns_alignment == "left":
-            alignment = lambda idx, s: ansi_ljust(s, sizes[idx])
-        elif columns_alignment == "center":
-            alignment = lambda idx, s: ansi_center(s, sizes[idx])
-        elif columns_alignment == "right":
-            alignment = lambda idx, s: ansi_rjust(s, sizes[idx])
-        else:
-            raise exceptions.InternalError("bad columns alignment argument")
-
-        for row in self.rows:
-            if hide_cols:
-                row = [v for idx, v in enumerate(row) if idx not in ignore_idx]
-            self._disp(
-                left
-                + col_sep.join([alignment(idx, c) for idx, c in enumerate(row)])
-                + right
-            )
-
-        if show_borders:
-            # bottom border
-            self._disp(
-                bottom_left
-                + bottom_sep.join([bottom * size for size in sizes])
-                + bottom_right
-            )
-            #  we return self so string can be used after display (table.display().string)
-        return self
-
-    def display_blank(self, **kwargs):
-        """Display table without visible borders"""
-        kwargs_ = {"col_sep": " ", "head_line_sep": " ", "show_borders": False}
-        kwargs_.update(kwargs)
-        return self.display(**kwargs_)
-
-
-async def fill_well_known_uri(command, path, key, meta_map=None):
-    """Look for URIs in well-known location and fill appropriate args if suitable
-
-    @param command(CommandBase): command instance
-        args of this instance will be updated with found values
-    @param path(unicode): absolute path to use as a starting point to look for URIs
-    @param key(unicode): key to look for
-    @param meta_map(dict, None): if not None, map metadata to arg name
-        key is metadata used attribute name
-        value is name to actually use, or None to ignore
-        use empty dict to only retrieve URI
-        possible keys are currently:
-            - labels
-    """
-    args = command.args
-    if args.service or args.node:
-        # we only look for URIs if a service and a node are not already specified
-        return
-
-    host = command.host
-
-    try:
-        uris_data = await host.bridge.uri_find(path, [key])
-    except Exception as e:
-        host.disp(f"can't find {key} URI: {e}", error=True)
-        host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-    try:
-        uri_data = uris_data[key]
-    except KeyError:
-        host.disp(
-            _(
-                "No {key} URI specified for this project, please specify service and "
-                "node"
-            ).format(key=key),
-            error=True,
-        )
-        host.quit(C.EXIT_NOT_FOUND)
-
-    uri = uri_data["uri"]
-
-    # set extra metadata if they are specified
-    for data_key in ["labels"]:
-        new_values_json = uri_data.get(data_key)
-        if uri_data is not None:
-            if meta_map is None:
-                dest = data_key
-            else:
-                dest = meta_map.get(data_key)
-                if dest is None:
-                    continue
-
-            try:
-                values = getattr(args, data_key)
-            except AttributeError:
-                raise exceptions.InternalError(f"there is no {data_key!r} arguments")
-            else:
-                if values is None:
-                    values = []
-                values.extend(json.loads(new_values_json))
-                setattr(args, dest, values)
-
-    parsed_uri = xmpp_uri.parse_xmpp_uri(uri)
-    try:
-        args.service = parsed_uri["path"]
-        args.node = parsed_uri["node"]
-    except KeyError:
-        host.disp(_("Invalid URI found: {uri}").format(uri=uri), error=True)
-        host.quit(C.EXIT_DATA_ERROR)
--- a/libervia/frontends/jp/constants.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from libervia.frontends.quick_frontend import constants
-from libervia.backend.tools.common.ansi import ANSI as A
-
-
-class Const(constants.Const):
-
-    APP_NAME = "Libervia CLI"
-    APP_COMPONENT = "CLI"
-    APP_NAME_ALT = "jp"
-    APP_NAME_FILE = "libervia_cli"
-    CONFIG_SECTION = APP_COMPONENT.lower()
-    PLUGIN_CMD = "commands"
-    PLUGIN_OUTPUT = "outputs"
-    OUTPUT_TEXT = "text"  # blob of unicode text
-    OUTPUT_DICT = "dict"  # simple key/value dictionary
-    OUTPUT_LIST = "list"
-    OUTPUT_LIST_DICT = "list_dict"  # list of dictionaries
-    OUTPUT_DICT_DICT = "dict_dict"  # dict  of nested dictionaries
-    OUTPUT_MESS = "mess"  # messages (chat)
-    OUTPUT_COMPLEX = "complex"  # complex data (e.g. multi-level dictionary)
-    OUTPUT_XML = "xml"  # XML node (as unicode string)
-    OUTPUT_LIST_XML = "list_xml"  # list of XML nodes (as unicode strings)
-    OUTPUT_XMLUI = "xmlui"  # XMLUI as unicode string
-    OUTPUT_LIST_XMLUI = "list_xmlui"  # list of XMLUI (as unicode strings)
-    OUTPUT_TYPES = (
-        OUTPUT_TEXT,
-        OUTPUT_DICT,
-        OUTPUT_LIST,
-        OUTPUT_LIST_DICT,
-        OUTPUT_DICT_DICT,
-        OUTPUT_MESS,
-        OUTPUT_COMPLEX,
-        OUTPUT_XML,
-        OUTPUT_LIST_XML,
-        OUTPUT_XMLUI,
-        OUTPUT_LIST_XMLUI,
-    )
-    OUTPUT_NAME_SIMPLE = "simple"
-    OUTPUT_NAME_XML = "xml"
-    OUTPUT_NAME_XML_RAW = "xml-raw"
-    OUTPUT_NAME_JSON = "json"
-    OUTPUT_NAME_JSON_RAW = "json-raw"
-
-    # Pubsub options flags
-    SERVICE = "service"  # service required
-    NODE = "node"  # node required
-    ITEM = "item"  # item required
-    SINGLE_ITEM = "single_item"  # only one item is allowed
-    MULTI_ITEMS = "multi_items"  # multiple items are allowed
-    NO_MAX = "no_max"  # don't add --max option for multi items
-    CACHE = "cache"  # add cache control flag
-
-    # ANSI
-    A_HEADER = A.BOLD + A.FG_YELLOW
-    A_SUBHEADER = A.BOLD + A.FG_RED
-    # A_LEVEL_COLORS may be used to cycle on colors according to depth of data
-    A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
-    A_SUCCESS = A.BOLD + A.FG_GREEN
-    A_FAILURE = A.BOLD + A.FG_RED
-    A_WARNING = A.BOLD + A.FG_RED
-    #  A_PROMPT_* is for shell
-    A_PROMPT_PATH = A.BOLD + A.FG_CYAN
-    A_PROMPT_SUF = A.BOLD
-    # Files
-    A_DIRECTORY = A.BOLD + A.FG_CYAN
-    A_FILE = A.FG_WHITE
--- a/libervia/frontends/jp/loops.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,145 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import sys
-import asyncio
-import logging as log
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-
-log.basicConfig(level=log.WARNING,
-                format='[%(name)s] %(message)s')
-
-USER_INTER_MSG = _("User interruption: good bye")
-
-
-class QuitException(BaseException):
-    """Quitting is requested
-
-    This is used to stop execution when host.quit() is called
-    """
-
-
-def get_jp_loop(bridge_name):
-    if 'dbus' in bridge_name:
-        import signal
-        import threading
-        from gi.repository import GLib
-
-        class JPLoop:
-
-            def run(self, jp, args, namespace):
-                signal.signal(signal.SIGINT, self._on_sigint)
-                self._glib_loop = GLib.MainLoop()
-                threading.Thread(target=self._glib_loop.run).start()
-                loop = asyncio.get_event_loop()
-                loop.run_until_complete(jp.main(args=args, namespace=namespace))
-                loop.run_forever()
-
-            def quit(self, exit_code):
-                loop = asyncio.get_event_loop()
-                loop.stop()
-                self._glib_loop.quit()
-                sys.exit(exit_code)
-
-            def call_later(self, delay, callback, *args):
-                """call a callback repeatedly
-
-                @param delay(int): delay between calls in s
-                @param callback(callable): method to call
-                    if the callback return True, the call will continue
-                    else the calls will stop
-                @param *args: args of the callbac
-                """
-                loop = asyncio.get_event_loop()
-                loop.call_later(delay, callback, *args)
-
-            def _on_sigint(self, sig_number, stack_frame):
-                """Called on keyboard interruption
-
-                Print user interruption message, set exit code and stop reactor
-                """
-                print("\r" + USER_INTER_MSG)
-                self.quit(C.EXIT_USER_CANCELLED)
-    else:
-        import signal
-        from twisted.internet import asyncioreactor
-        asyncioreactor.install()
-        from twisted.internet import reactor, defer
-
-        class JPLoop:
-
-            def __init__(self):
-                # exit code must be set when using quit, so if it's not set
-                # something got wrong and we must report it
-                self._exit_code = C.EXIT_INTERNAL_ERROR
-
-            def run(self, jp, *args):
-                self.jp = jp
-                signal.signal(signal.SIGINT, self._on_sigint)
-                defer.ensureDeferred(self._start(jp, *args))
-                try:
-                    reactor.run(installSignalHandlers=False)
-                except SystemExit as e:
-                    self._exit_code = e.code
-                sys.exit(self._exit_code)
-
-            async def _start(self, jp, *args):
-                fut = asyncio.ensure_future(jp.main(*args))
-                try:
-                    await defer.Deferred.fromFuture(fut)
-                except BaseException:
-                    import traceback
-                    traceback.print_exc()
-                    jp.quit(1)
-
-            def quit(self, exit_code):
-                self._exit_code = exit_code
-                reactor.stop()
-
-            def _timeout_cb(self, args, callback, delay):
-                try:
-                    ret = callback(*args)
-                # FIXME: temporary hack to avoid traceback when using XMLUI
-                #        to be removed once create_task is not used anymore in
-                #        xmlui_manager (i.e. once libervia.frontends.tools.xmlui fully supports
-                #        async syntax)
-                except QuitException:
-                    return
-                if ret:
-                    reactor.callLater(delay, self._timeout_cb, args, callback, delay)
-
-            def call_later(self, delay, callback, *args):
-                reactor.callLater(delay, self._timeout_cb, args, callback, delay)
-
-            def _on_sigint(self, sig_number, stack_frame):
-                """Called on keyboard interruption
-
-                Print user interruption message, set exit code and stop reactor
-                """
-                print("\r" + USER_INTER_MSG)
-                self._exit_code = C.EXIT_USER_CANCELLED
-                reactor.callFromThread(reactor.stop)
-
-
-    if bridge_name == "embedded":
-        raise NotImplementedError
-        # from sat.core import sat_main
-        # sat = sat_main.SAT()
-
-    return JPLoop
--- a/libervia/frontends/jp/output_std.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,127 +0,0 @@
-#! /usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-"""Standard outputs"""
-
-
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.tools import jid
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import date_utils
-import json
-
-__outputs__ = ["Simple", "Json"]
-
-
-class Simple(object):
-    """Default outputs"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_SIMPLE, self.simple_print)
-        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_SIMPLE, self.list)
-        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict)
-        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_SIMPLE, self.list_dict)
-        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict_dict)
-        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_SIMPLE, self.messages)
-        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_SIMPLE, self.simple_print)
-
-    def simple_print(self, data):
-        self.host.disp(str(data))
-
-    def list(self, data):
-        self.host.disp("\n".join(data))
-
-    def dict(self, data, indent=0, header_color=C.A_HEADER):
-        options = self.host.parse_output_options()
-        self.host.check_output_options({"no-header"}, options)
-        show_header = not "no-header" in options
-        for k, v in data.items():
-            if show_header:
-                header = A.color(header_color, k) + ": "
-            else:
-                header = ""
-
-            self.host.disp(
-                (
-                    "{indent}{header}{value}".format(
-                        indent=indent * " ", header=header, value=v
-                    )
-                )
-            )
-
-    def list_dict(self, data):
-        for idx, datum in enumerate(data):
-            if idx:
-                self.host.disp("\n")
-            self.dict(datum)
-
-    def dict_dict(self, data):
-        for key, sub_dict in data.items():
-            self.host.disp(A.color(C.A_HEADER, key))
-            self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER)
-
-    def messages(self, data):
-        # TODO: handle lang, and non chat message (normal, headline)
-        for mess_data in data:
-            (uid, timestamp, from_jid, to_jid, message, subject, mess_type,
-             extra) = mess_data
-            time_str = date_utils.date_fmt(timestamp, "auto_day",
-                                           tz_info=date_utils.TZ_LOCAL)
-            from_jid = jid.JID(from_jid)
-            if mess_type == C.MESS_TYPE_GROUPCHAT:
-                nick = from_jid.resource
-            else:
-                nick = from_jid.node
-
-            if self.host.own_jid is not None and self.host.own_jid.bare == from_jid.bare:
-                nick_color = A.BOLD + A.FG_BLUE
-            else:
-                nick_color = A.BOLD + A.FG_YELLOW
-            message = list(message.values())[0] if message else ""
-
-            self.host.disp(A.color(
-                A.FG_CYAN, '['+time_str+'] ',
-                nick_color, nick, A.RESET, A.BOLD, '> ',
-                A.RESET, message))
-
-
-class Json(object):
-    """outputs in json format"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_JSON, self.dump)
-        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON_RAW, self.dump)
-
-    def dump(self, data):
-        self.host.disp(json.dumps(data, default=str))
-
-    def dump_pretty(self, data):
-        self.host.disp(json.dumps(data, indent=4, default=str))
--- a/libervia/frontends/jp/output_template.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,138 +0,0 @@
-#! /usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-"""Standard outputs"""
-
-
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from libervia.backend.core import log
-from libervia.backend.tools.common import template
-from functools import partial
-import logging
-import webbrowser
-import tempfile
-import os.path
-
-__outputs__ = ["Template"]
-TEMPLATE = "template"
-OPTIONS = {"template", "browser", "inline-css"}
-
-
-class Template(object):
-    """outputs data using SàT templates"""
-
-    def __init__(self, jp):
-        self.host = jp
-        jp.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render)
-
-    def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir):
-        """Get front URL for temporary directory"""
-        template_data = ctx['template_data']
-        return "file://" + os.path.join(tmp_dir, template_data.theme, relative_url)
-
-    def _do_render(self, template_path, css_inline, **kwargs):
-        try:
-            return self.renderer.render(template_path, css_inline=css_inline, **kwargs)
-        except template.TemplateNotFound:
-            self.host.disp(_("Can't find requested template: {template_path}")
-                .format(template_path=template_path), error=True)
-            self.host.quit(C.EXIT_NOT_FOUND)
-
-    def render(self, data):
-        """render output data using requested template
-
-        template to render the data can be either command's TEMPLATE or
-        template output_option requested by user.
-        @param data(dict): data is a dict which map from variable name to use in template
-            to the variable itself.
-            command's template_data_mapping attribute will be used if it exists to convert
-            data to a dict usable by the template.
-        """
-        # media_dir is needed for the template
-        self.host.media_dir = self.host.bridge.config_get("", "media_dir")
-        cmd = self.host.command
-        try:
-            template_path = cmd.TEMPLATE
-        except AttributeError:
-            if not "template" in cmd.args.output_opts:
-                self.host.disp(_(
-                    "no default template set for this command, you need to specify a "
-                    "template using --oo template=[path/to/template.html]"),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-
-        options = self.host.parse_output_options()
-        self.host.check_output_options(OPTIONS, options)
-        try:
-            template_path = options["template"]
-        except KeyError:
-            # template is not specified, we use default one
-            pass
-        if template_path is None:
-            self.host.disp(_("Can't parse template, please check its syntax"),
-                           error=True)
-            self.host.quit(C.EXIT_BAD_ARG)
-
-        try:
-            mapping_cb = cmd.template_data_mapping
-        except AttributeError:
-            kwargs = data
-        else:
-            kwargs = mapping_cb(data)
-
-        css_inline = "inline-css" in options
-
-        if "browser" in options:
-            template_name = os.path.basename(template_path)
-            tmp_dir = tempfile.mkdtemp()
-            front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir)
-            self.renderer = template.Renderer(
-                self.host, front_url_filter=front_url_filter, trusted=True)
-            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
-            self.host.disp(_(
-                "Browser opening requested.\n"
-                "Temporary files are put in the following directory, you'll have to "
-                "delete it yourself once finished viewing: {}").format(tmp_dir))
-            tmp_file = os.path.join(tmp_dir, template_name)
-            with open(tmp_file, "w") as f:
-                f.write(rendered.encode("utf-8"))
-            theme, theme_root_path = self.renderer.get_theme_and_root(template_path)
-            if theme is None:
-                # we have an absolute path
-                webbrowser
-            static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR)
-            if os.path.exists(static_dir):
-                # we have to copy static files in a subdirectory, to avoid file download
-                # to be blocked by same origin policy
-                import shutil
-                shutil.copytree(
-                    static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR)
-                )
-            webbrowser.open(tmp_file)
-        else:
-            # FIXME: Q&D way to disable template logging
-            #        logs are overcomplicated, and need to be reworked
-            template_logger = log.getLogger("sat.tools.common.template")
-            template_logger.log = lambda *args: None
-
-            logging.disable(logging.WARNING)
-            self.renderer = template.Renderer(self.host, trusted=True)
-            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
-            self.host.disp(rendered)
--- a/libervia/frontends/jp/output_xml.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#! /usr/bin/env python3
-
-# Libervia CLI frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-"""Standard outputs"""
-
-
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from lxml import etree
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-import sys
-
-try:
-    import pygments
-    from pygments.lexers.html import XmlLexer
-    from pygments.formatters import TerminalFormatter
-except ImportError:
-    pygments = None
-
-
-__outputs__ = ["XML"]
-
-
-class XML(object):
-    """Outputs for XML"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML, self.pretty, default=True)
-        host.register_output(
-            C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML, self.pretty_list, default=True
-        )
-        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML_RAW, self.raw)
-        host.register_output(C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML_RAW, self.list_raw)
-
-    def colorize(self, xml):
-        if pygments is None:
-            self.host.disp(
-                _(
-                    "Pygments is not available, syntax highlighting is not possible. "
-                    "Please install if from http://pygments.org or with pip install "
-                    "pygments"
-                ),
-                error=True,
-            )
-            return xml
-        if not sys.stdout.isatty():
-            return xml
-        lexer = XmlLexer(encoding="utf-8")
-        formatter = TerminalFormatter(bg="dark")
-        return pygments.highlight(xml, lexer, formatter)
-
-    def format(self, data, pretty=True):
-        parser = etree.XMLParser(remove_blank_text=True)
-        tree = etree.fromstring(data, parser)
-        xml = etree.tostring(tree, encoding="unicode", pretty_print=pretty)
-        return self.colorize(xml)
-
-    def format_no_pretty(self, data):
-        return self.format(data, pretty=False)
-
-    def pretty(self, data):
-        self.host.disp(self.format(data))
-
-    def pretty_list(self, data, separator="\n"):
-        list_pretty = list(map(self.format, data))
-        self.host.disp(separator.join(list_pretty))
-
-    def raw(self, data):
-        self.host.disp(self.format_no_pretty(data))
-
-    def list_raw(self, data, separator="\n"):
-        list_no_pretty = list(map(self.format_no_pretty, data))
-        self.host.disp(separator.join(list_no_pretty))
--- a/libervia/frontends/jp/output_xmlui.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,49 +0,0 @@
-#! /usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-"""Standard outputs"""
-
-
-from libervia.frontends.jp.constants import Const as C
-from libervia.frontends.jp import xmlui_manager
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-__outputs__ = ["XMLUI"]
-
-
-class XMLUI(object):
-    """Outputs for XMLUI"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_XMLUI, "simple", self.xmlui, default=True)
-        host.register_output(
-            C.OUTPUT_LIST_XMLUI, "simple", self.xmlui_list, default=True
-        )
-
-    async def xmlui(self, data):
-        xmlui = xmlui_manager.create(self.host, data)
-        await xmlui.show(values_only=True, read_only=True)
-        self.host.disp("")
-
-    async def xmlui_list(self, data):
-        for d in data:
-            await self.xmlui(d)
--- a/libervia/frontends/jp/xml_tools.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from libervia.backend.core.i18n import _
-from libervia.frontends.jp.constants import Const as C
-
-def etree_parse(cmd, raw_xml, reraise=False):
-    """import lxml and parse raw XML
-
-    @param cmd(CommandBase): current command instance
-    @param raw_xml(file, str): an XML bytestring, string or file-like object
-    @param reraise(bool): if True, re raise exception on parse error instead of doing a
-        parser.error (which terminate the execution)
-    @return (tuple(etree.Element, module): parsed element, etree module
-    """
-    try:
-        from lxml import etree
-    except ImportError:
-        cmd.disp(
-            'lxml module must be installed, please install it with "pip install lxml"',
-            error=True,
-        )
-        cmd.host.quit(C.EXIT_ERROR)
-    try:
-        if isinstance(raw_xml, str):
-            parser = etree.XMLParser(remove_blank_text=True)
-            element = etree.fromstring(raw_xml, parser)
-        else:
-            element = etree.parse(raw_xml).getroot()
-    except Exception as e:
-        if reraise:
-            raise e
-        cmd.parser.error(
-            _("Can't parse the payload XML in input: {msg}").format(msg=e)
-        )
-    return element, etree
-
-def get_payload(cmd, element):
-    """Retrieve payload element and exit with and error if not found
-
-    @param element(etree.Element): root element
-    @return element(etree.Element): payload element
-    """
-    if element.tag in ("item", "{http://jabber.org/protocol/pubsub}item"):
-        if len(element) > 1:
-            cmd.disp(_("<item> can only have one child element (the payload)"),
-                     error=True)
-            cmd.host.quit(C.EXIT_DATA_ERROR)
-        element = element[0]
-    return element
--- a/libervia/frontends/jp/xmlui_manager.py	Fri Jun 02 14:12:38 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,652 +0,0 @@
-#!/usr/bin/env python3
-
-
-# JP: a SàT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from functools import partial
-from libervia.backend.core.log import getLogger
-from libervia.frontends.tools import xmlui as xmlui_base
-from libervia.frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-
-log = getLogger(__name__)
-
-# workflow constants
-
-SUBMIT = "SUBMIT"  # submit form
-
-
-## Widgets ##
-
-
-class Base(object):
-    """Base for Widget and Container"""
-
-    type = None
-    _root = None
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        self.host = self.xmlui_parent.host
-
-    @property
-    def root(self):
-        """retrieve main XMLUI parent class"""
-        if self._root is not None:
-            return self._root
-        root = self
-        while not isinstance(root, xmlui_base.XMLUIBase):
-            root = root.xmlui_parent
-        self._root = root
-        return root
-
-    def disp(self, *args, **kwargs):
-        self.host.disp(*args, **kwargs)
-
-
-class Widget(Base):
-    category = "widget"
-    enabled = True
-
-    @property
-    def name(self):
-        return self._xmlui_name
-
-    async def show(self):
-        """display current widget
-
-        must be overriden by subclasses
-        """
-        raise NotImplementedError(self.__class__)
-
-    def verbose_name(self, elems=None, value=None):
-        """add name in color to the elements
-
-        helper method to display name which can then be used to automate commands
-        elems is only modified if verbosity is > 0
-        @param elems(list[unicode], None): elements to display
-            None to display name directly
-        @param value(unicode, None): value to show
-            use self.name if None
-        """
-        if value is None:
-            value = self.name
-        if self.host.verbosity:
-            to_disp = [
-                A.FG_MAGENTA,
-                " " if elems else "",
-                "({})".format(value),
-                A.RESET,
-            ]
-            if elems is None:
-                self.host.disp(A.color(*to_disp))
-            else:
-                elems.extend(to_disp)
-
-
-class ValueWidget(Widget):
-    def __init__(self, xmlui_parent, value):
-        super(ValueWidget, self).__init__(xmlui_parent)
-        self.value = value
-
-    @property
-    def values(self):
-        return [self.value]
-
-
-class InputWidget(ValueWidget):
-    def __init__(self, xmlui_parent, value, read_only=False):
-        super(InputWidget, self).__init__(xmlui_parent, value)
-        self.read_only = read_only
-
-    def _xmlui_get_value(self):
-        return self.value
-
-
-class OptionsWidget(Widget):
-    def __init__(self, xmlui_parent, options, selected, style):
-        super(OptionsWidget, self).__init__(xmlui_parent)
-        self.options = options
-        self.selected = selected
-        self.style = style
-
-    @property
-    def values(self):
-        return self.selected
-
-    @values.setter
-    def values(self, values):
-        self.selected = values
-
-    @property
-    def value(self):
-        return self.selected[0]
-
-    @value.setter
-    def value(self, value):
-        self.selected = [value]
-
-    def _xmlui_select_value(self, value):
-        self.value = value
-
-    def _xmlui_select_values(self, values):
-        self.values = values
-
-    def _xmlui_get_selected_values(self):
-        return self.values
-
-    @property
-    def labels(self):
-        """return only labels from self.items"""
-        for value, label in self.items:
-            yield label
-
-    @property
-    def items(self):
-        """return suitable items, according to style"""
-        no_select = self.no_select
-        for value, label in self.options:
-            if no_select or value in self.selected:
-                yield value, label
-
-    @property
-    def inline(self):
-        return "inline" in self.style
-
-    @property
-    def no_select(self):
-        return "noselect" in self.style
-
-
-class EmptyWidget(xmlui_base.EmptyWidget, Widget):
-    def __init__(self, xmlui_parent):
-        Widget.__init__(self, xmlui_parent)
-
-    async def show(self):
-        self.host.disp("")
-
-
-class TextWidget(xmlui_base.TextWidget, ValueWidget):
-    type = "text"
-
-    async def show(self):
-        self.host.disp(self.value)
-
-
-class LabelWidget(xmlui_base.LabelWidget, ValueWidget):
-    type = "label"
-
-    @property
-    def for_name(self):
-        try:
-            return self._xmlui_for_name
-        except AttributeError:
-            return None
-
-    async def show(self, end="\n", ansi=""):
-        """show label
-
-        @param end(str): same as for [JP.disp]
-        @param ansi(unicode): ansi escape code to print before label
-        """
-        self.disp(A.color(ansi, self.value), end=end)
-
-
-class JidWidget(xmlui_base.JidWidget, TextWidget):
-    type = "jid"
-
-
-class StringWidget(xmlui_base.StringWidget, InputWidget):
-    type = "string"
-
-    async def show(self):
-        if self.read_only or self.root.read_only:
-            self.disp(self.value)
-        else:
-            elems = []
-            self.verbose_name(elems)
-            if self.value:
-                elems.append(_("(enter: {value})").format(value=self.value))
-            elems.extend([C.A_HEADER, "> "])
-            value = await self.host.ainput(A.color(*elems))
-            if value:
-                #  TODO: empty value should be possible
-                #       an escape key should be used for default instead of enter with empty value
-                self.value = value
-
-
-class JidInputWidget(xmlui_base.JidInputWidget, StringWidget):
-    type = "jid_input"
-
-
-class PasswordWidget(xmlui_base.PasswordWidget, StringWidget):
-    type = "password"
-
-
-class TextBoxWidget(xmlui_base.TextWidget, StringWidget):
-    type = "textbox"
-    # TODO: use a more advanced input method
-
-    async def show(self):
-        self.verbose_name()
-        if self.read_only or self.root.read_only:
-            self.disp(self.value)
-        else:
-            if self.value:
-                self.disp(
-                    A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "")
-                )
-
-            values = []
-            while True:
-                try:
-                    if not values:
-                        line = await self.host.ainput(
-                            A.color(C.A_HEADER, "[Ctrl-D to finish]> ")
-                        )
-                    else:
-                        line = await self.host.ainput()
-                    values.append(line)
-                except EOFError:
-                    break
-
-            self.value = "\n".join(values).rstrip()
-
-
-class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget):
-    type = "xhtmlbox"
-
-    async def show(self):
-        # FIXME: we use bridge in a blocking way as permitted by python-dbus
-        #        this only for now to make it simpler, it must be refactored
-        #        to use async when jp will be fully async (expected for 0.8)
-        self.value = await self.host.bridge.syntax_convert(
-            self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile
-        )
-        await super(XHTMLBoxWidget, self).show()
-
-
-class ListWidget(xmlui_base.ListWidget, OptionsWidget):
-    type = "list"
-    # TODO: handle flags, notably multi
-
-    async def show(self):
-        if self.root.values_only:
-            for value in self.values:
-                self.disp(self.value)
-                return
-        if not self.options:
-            return
-
-            # list display
-        self.verbose_name()
-
-        for idx, (value, label) in enumerate(self.options):
-            elems = []
-            if not self.root.read_only:
-                elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "])
-            elems.append(label)
-            self.verbose_name(elems, value)
-            self.disp(A.color(*elems))
-
-        if self.root.read_only:
-            return
-
-        if len(self.options) == 1:
-            # we have only one option, no need to ask
-            self.value = self.options[0][0]
-            return
-
-            #  we ask use to choose an option
-        choice = None
-        limit_max = len(self.options) - 1
-        while choice is None or choice < 0 or choice > limit_max:
-            choice = await self.host.ainput(
-                A.color(
-                    C.A_HEADER,
-                    _("your choice (0-{limit_max}): ").format(limit_max=limit_max),
-                )
-            )
-            try:
-                choice = int(choice)
-            except ValueError:
-                choice = None
-        self.value = self.options[choice][0]
-        self.disp("")
-
-
-class BoolWidget(xmlui_base.BoolWidget, InputWidget):
-    type = "bool"
-
-    async def show(self):
-        disp_true = A.color(A.FG_GREEN, "TRUE")
-        disp_false = A.color(A.FG_RED, "FALSE")
-        if self.read_only or self.root.read_only:
-            self.disp(disp_true if self.value else disp_false)
-        else:
-            self.disp(
-                A.color(
-                    C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else ""
-                )
-            )
-            self.disp(
-                A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "")
-            )
-            choice = None
-            while choice not in ("0", "1"):
-                elems = [C.A_HEADER, _("your choice (0,1): ")]
-                self.verbose_name(elems)
-                choice = await self.host.ainput(A.color(*elems))
-            self.value = bool(int(choice))
-            self.disp("")
-
-    def _xmlui_get_value(self):
-        return C.bool_const(self.value)
-
-        ## Containers ##
-
-
-class Container(Base):
-    category = "container"
-
-    def __init__(self, xmlui_parent):
-        super(Container, self).__init__(xmlui_parent)
-        self.children = []
-
-    def __iter__(self):
-        return iter(self.children)
-
-    def _xmlui_append(self, widget):
-        self.children.append(widget)
-
-    def _xmlui_remove(self, widget):
-        self.children.remove(widget)
-
-    async def show(self):
-        for child in self.children:
-            await child.show()
-
-
-class VerticalContainer(xmlui_base.VerticalContainer, Container):
-    type = "vertical"
-
-
-class PairsContainer(xmlui_base.PairsContainer, Container):
-    type = "pairs"
-
-
-class LabelContainer(xmlui_base.PairsContainer, Container):
-    type = "label"
-
-    async def show(self):
-        for child in self.children:
-            end = "\n"
-            # we check linked widget type
-            # to see if we want the label on the same line or not
-            if child.type == "label":
-                for_name = child.for_name
-                if for_name:
-                    for_widget = self.root.widgets[for_name]
-                    wid_type = for_widget.type
-                    if self.root.values_only or wid_type in (
-                        "text",
-                        "string",
-                        "jid_input",
-                    ):
-                        end = " "
-                    elif wid_type == "bool" and for_widget.read_only:
-                        end = " "
-                await child.show(end=end, ansi=A.FG_CYAN)
-            else:
-                await child.show()
-
-                ## Dialogs ##
-
-
-class Dialog(object):
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        self.host = self.xmlui_parent.host
-
-    def disp(self, *args, **kwargs):
-        self.host.disp(*args, **kwargs)
-
-    async def show(self):
-        """display current dialog
-
-        must be overriden by subclasses
-        """
-        raise NotImplementedError(self.__class__)
-
-
-class MessageDialog(xmlui_base.MessageDialog, Dialog):
-    def __init__(self, xmlui_parent, title, message, level):
-        Dialog.__init__(self, xmlui_parent)
-        xmlui_base.MessageDialog.__init__(self, xmlui_parent)
-        self.title, self.message, self.level = title, message, level
-
-    async def show(self):
-        # TODO: handle level
-        if self.title:
-            self.disp(A.color(C.A_HEADER, self.title))
-        self.disp(self.message)
-
-
-class NoteDialog(xmlui_base.NoteDialog, Dialog):
-    def __init__(self, xmlui_parent, title, message, level):
-        Dialog.__init__(self, xmlui_parent)
-        xmlui_base.NoteDialog.__init__(self, xmlui_parent)
-        self.title, self.message, self.level = title, message, level
-
-    async def show(self):
-        # TODO: handle title
-        error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR)
-        if self.level == C.XMLUI_DATA_LVL_WARNING:
-            msg = A.color(C.A_WARNING, self.message)
-        elif self.level == C.XMLUI_DATA_LVL_ERROR:
-            msg = A.color(C.A_FAILURE, self.message)
-        else:
-            msg = self.message
-        self.disp(msg, error=error)
-
-
-class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog):
-    def __init__(self, xmlui_parent, title, message, level, buttons_set):
-        Dialog.__init__(self, xmlui_parent)
-        xmlui_base.ConfirmDialog.__init__(self, xmlui_parent)
-        self.title, self.message, self.level, self.buttons_set = (
-            title,
-            message,
-            level,
-            buttons_set,
-        )
-
-    async def show(self):
-        # TODO: handle buttons_set and level
-        self.disp(self.message)
-        if self.title:
-            self.disp(A.color(C.A_HEADER, self.title))
-        input_ = None
-        while input_ not in ("y", "n"):
-            input_ = await self.host.ainput(f"{self.message} (y/n)? ")
-            input_ = input_.lower()
-        if input_ == "y":
-            self._xmlui_validated()
-        else:
-            self._xmlui_cancelled()
-
-            ## Factory ##
-
-
-class WidgetFactory(object):
-    def __getattr__(self, attr):
-        if attr.startswith("create"):
-            cls = globals()[attr[6:]]
-            return cls
-
-
-class XMLUIPanel(xmlui_base.AIOXMLUIPanel):
-    widget_factory = WidgetFactory()
-    _actions = 0  # use to keep track of bridge's action_launch calls
-    read_only = False
-    values_only = False
-    workflow = None
-    _submit_cb = None
-
-    def __init__(
-        self,
-        host,
-        parsed_dom,
-        title=None,
-        flags=None,
-        callback=None,
-        ignore=None,
-        whitelist=None,
-        profile=None,
-    ):
-        xmlui_base.XMLUIPanel.__init__(
-            self,
-            host,
-            parsed_dom,
-            title=title,
-            flags=flags,
-            ignore=ignore,
-            whitelist=whitelist,
-            profile=host.profile,
-        )
-        self.submitted = False
-
-    @property
-    def command(self):
-        return self.host.command
-
-    def disp(self, *args, **kwargs):
-        self.host.disp(*args, **kwargs)
-
-    async def show(self, workflow=None, read_only=False, values_only=False):
-        """display the panel
-
-        @param workflow(list, None): command to execute if not None
-            put here for convenience, the main workflow is the class attribute
-            (because workflow can continue in subclasses)
-            command are a list of consts or lists:
-                - SUBMIT is the only constant so far, it submits the XMLUI
-                - list must contain widget name/widget value to fill
-        @param read_only(bool): if True, don't request values
-        @param values_only(bool): if True, only show select values (imply read_only)
-        """
-        self.read_only = read_only
-        self.values_only = values_only
-        if self.values_only:
-            self.read_only = True
-        if workflow:
-            XMLUIPanel.workflow = workflow
-        if XMLUIPanel.workflow:
-            await self.run_workflow()
-        else:
-            await self.main_cont.show()
-
-    async def run_workflow(self):
-        """loop into workflow commands and execute commands
-
-        SUBMIT will interrupt workflow (which will be continue on callback)
-        @param workflow(list): same as [show]
-        """
-        workflow = XMLUIPanel.workflow
-        while True:
-            try:
-                cmd = workflow.pop(0)
-            except IndexError:
-                break
-            if cmd == SUBMIT:
-                await self.on_form_submitted()
-                self.submit_id = None  # avoid double submit
-                return
-            elif isinstance(cmd, list):
-                name, value = cmd
-                widget = self.widgets[name]
-                if widget.type == "bool":
-                    value = C.bool(value)
-                widget.value = value
-        await self.show()
-
-    async def submit_form(self, callback=None):
-        XMLUIPanel._submit_cb = callback
-        await self.on_form_submitted()
-
-    async def on_form_submitted(self, ignore=None):
-        # self.submitted is a Q&D workaround to avoid
-        # double submit when a workflow is set
-        if self.submitted:
-            return
-        self.submitted = True
-        await super(XMLUIPanel, self).on_form_submitted(ignore)
-
-    def _xmlui_close(self):
-        pass
-
-    async def _launch_action_cb(self, data):
-        XMLUIPanel._actions -= 1
-        assert XMLUIPanel._actions >= 0
-        if "xmlui" in data:
-            xmlui_raw = data["xmlui"]
-            xmlui = create(self.host, xmlui_raw)
-            await xmlui.show()
-            if xmlui.submit_id:
-                await xmlui.on_form_submitted()
-                # TODO: handle data other than XMLUI
-        if not XMLUIPanel._actions:
-            if self._submit_cb is None:
-                self.host.quit()
-            else:
-                self._submit_cb()
-
-    async def _xmlui_launch_action(self, action_id, data):
-        XMLUIPanel._actions += 1
-        try:
-            data = data_format.deserialise(
-                await self.host.bridge.action_launch(
-                    action_id,
-                    data_format.serialise(data),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't launch XMLUI action: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self._launch_action_cb(data)
-
-
-class XMLUIDialog(xmlui_base.XMLUIDialog):
-    type = "dialog"
-    dialog_factory = WidgetFactory()
-    read_only = False
-
-    async def show(self, __=None):
-        await self.dlg.show()
-
-    def _xmlui_close(self):
-        pass
-
-
-create = partial(
-    xmlui_base.create,
-    class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog},
-)
--- a/setup.py	Fri Jun 02 14:12:38 2023 +0200
+++ b/setup.py	Fri Jun 02 14:54:26 2023 +0200
@@ -132,9 +132,9 @@
             "sat = libervia.backend.core.launcher:Launcher.run",
 
             # CLI + aliases
-            "libervia-cli = libervia.frontends.jp.base:LiberviaCli.run",
-            "li = libervia.frontends.jp.base:LiberviaCli.run",
-            "jp = libervia.frontends.jp.base:LiberviaCli.run",
+            "libervia-cli = libervia.cli.base:LiberviaCli.run",
+            "li = libervia.cli.base:LiberviaCli.run",
+            "jp = libervia.cli.base:LiberviaCli.run",
 
             # TUI + alias
             "libervia-tui = libervia.frontends.primitivus.base:PrimitivusApp.run",