diff --git a/invenio_utilities_tuw/cli/__init__.py b/invenio_utilities_tuw/cli/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..838437b65d80849bf6fecb82139a956fe4c783be
--- /dev/null
+++ b/invenio_utilities_tuw/cli/__init__.py
@@ -0,0 +1,5 @@
+"""CLI commands for Invenio-Utilities-TUW."""
+
+from .cli import utilities
+
+__all__ = (utilities,)
diff --git a/invenio_utilities_tuw/cli/cli.py b/invenio_utilities_tuw/cli/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..e581bd5101397f1ea54939f2139ac2ce291978b4
--- /dev/null
+++ b/invenio_utilities_tuw/cli/cli.py
@@ -0,0 +1,16 @@
+"""CLI commands for Invenio-Utilities-TUW."""
+
+import click
+
+from .draft import draft
+from .users import users
+
+
+@click.group()
+def utilities():
+    """Utility commands for InvenioRDM."""
+    pass
+
+
+utilities.add_command(draft)
+utilities.add_command(users)
diff --git a/invenio_utilities_tuw/cli.py b/invenio_utilities_tuw/cli/draft.py
similarity index 67%
rename from invenio_utilities_tuw/cli.py
rename to invenio_utilities_tuw/cli/draft.py
index bb2e58b17436030338a7463417fa9f82b72127b8..40f9e5782acea84749ae49679dd3c4e04072a2cd 100644
--- a/invenio_utilities_tuw/cli.py
+++ b/invenio_utilities_tuw/cli/draft.py
@@ -1,17 +1,12 @@
-"""CLI commands for Invenio-Utilities-TUW."""
+"""Management commands for drafts."""
 
 import json
 import os
 import sys
-from os.path import basename, join, isfile, isdir
+from os.path import basename, isdir, isfile, join
 
 import click
 from flask.cli import with_appcontext
-from flask_principal import Identity
-from invenio_access import any_user
-from invenio_access.utils import get_identity
-from invenio_accounts import current_accounts
-from invenio_pidstore.models import PersistentIdentifier
 from invenio_rdm_records.records.models import DraftMetadata
 from invenio_rdm_records.services.services import (
     BibliographicDraftFilesService as DraftFileService,
@@ -20,50 +15,12 @@ from invenio_rdm_records.services.services import (
     BibliographicRecordService as RecordService,
 )
 
-
-def create_record_from_metadata(metadata_file_path, identity):
-    """Create a draft from the metadata in the specified JSON file."""
-    metadata = None
-    with open(metadata_file_path, "r") as metadata_file:
-        metadata = json.load(metadata_file)
-
-    if metadata is None:
-        raise Exception("not a valid json file: %s" % metadata_file_path)
-
-    service = RecordService()
-    draft = service.create(identity=identity, data=metadata)
-    return draft
-
-
-def get_identity_for_user(user):
-    """Get the Identity for the user specified via email or ID."""
-    identity = None
-    if user is not None:
-        # note: this seems like the canonical way to go
-        #       'as_user' can be either an integer (id) or email address
-        u = current_accounts.datastore.get_user(user)
-        if u is not None:
-            identity = get_identity(u)
-        else:
-            raise LookupError("user not found: %s" % user)
-
-    if identity is None:
-        identity = Identity(1)
-        identity.provides.add(any_user)
-
-    return identity
-
-
-def convert_to_recid(pid_value, pid_type):
-    if pid_type != "recid":
-        pid_value = (
-            PersistentIdentifier.query.filter_by(pid_value=pid, pid_type=pid_type)
-            .first()
-            .pid_value
-        )
-
-    return pid_value
-
+from .utils import (
+    convert_to_recid,
+    create_record_from_metadata,
+    get_identity_for_user,
+    patch_metadata,
+)
 
 option_as_user = click.option(
     "--as-user",
@@ -93,25 +50,36 @@ option_pid_value = click.option(
 
 
 @click.group()
-def utilities():
-    """Utility commands for InvenioRDM."""
-    pass
-
-
-@utilities.group()
 def draft():
     """Utility commands for creation and publication of drafts."""
     pass
 
 
-@draft.group()
-def files():
-    """Manage files deposited with the draft."""
-    pass
+@draft.command("list")
+@option_as_user
+@with_appcontext
+def list_draft(user):
+    """List all drafts accessible to the given user."""
+    identity = get_identity_for_user(user)
+    service = RecordService()
+    recids = [
+        dm.json["id"]
+        for dm in DraftMetadata.query.all()
+        if dm is not None and dm.json is not None
+    ]
+
+    for recid in recids:
+        try:
+            draft = service.read_draft(id_=recid, identity=identity)
+            click.secho(
+                "{} - {}".format(draft.id, draft.data["metadata"]["title"]), fg="green"
+            )
+        except:
+            pass
 
 
 @draft.command("create")
-@click.argument("metadata_path")
+@click.argument("metadata_path", type=click.Path(exists=True))
 @option_as_user
 @click.option(
     "--publish",
@@ -122,7 +90,16 @@ def files():
 )
 @with_appcontext
 def create_draft(metadata_path, publish, user):
-    """Create a new record draft with the specified metadata."""
+    """Create a new record draft with the specified metadata.
+
+    The specified metadata path can either point to a JSON file containing the metadata,
+    or it can point to a directory.
+    In the former case, no files will be added to the created draft.
+    In the latter case, it is assumed that the directory contains a file called
+    "metadata.json".
+    Further, all files contained in the "files/" subdirectory will be added to the
+    draft, if such a subdirectory exists.
+    """
     recid = None
     identity = get_identity_for_user(user)
 
@@ -140,15 +117,14 @@ def create_draft(metadata_path, publish, user):
         recid = draft["id"]
         file_names = []
         if isdir(deposit_files_path):
-            dir_contents = os.listdir(deposit_files_path)
-            file_names = [basename(fn) for fn in dir_contents if isfile(join(deposit_files_path, fn))]
-            if len(dir_contents) != len(file_names):
-                ignored = [basename(fn) for fn in dir_contents if not isfile(join(deposit_files_path, fn))]
-                click.secho(
-                    "ignored in '{}': {}".format(deposit_files_path, ignored),
-                    fg="red",
-                    err=True,
-                )
+            exists = lambda fn: isfile(join(deposit_files_path, fn))
+            content = os.listdir(deposit_files_path)
+            file_names = [basename(fn) for fn in content if exists(fn)]
+
+            if len(content) != len(file_names):
+                ignored = [basename(fn) for fn in content if not exists(fn)]
+                msg = "ignored in '{}': {}".format(deposit_files_path, ignored)
+                click.secho(msg, fg="red", err=True)
 
         service = DraftFileService()
         service.init_files(
@@ -173,55 +149,65 @@ def create_draft(metadata_path, publish, user):
     click.secho(recid, fg="green")
 
 
-@draft.command("list")
+@draft.command("update")
+@click.argument("metadata_file", type=click.File("r"))
+@option_pid_value
+@option_pid_type
 @option_as_user
+@click.option(
+    "--patch/--replace",
+    "-p/-r",
+    default=False,
+    help="replace the draft's metadata entirely, or leave unmentioned fields as-is",
+)
 @with_appcontext
-def create_draft(user):
-    """List all drafts accessible to the given user."""
+def update_draft(metadata_file, pid, pid_type, user, patch):
+    """Update the specified draft's metadata."""
+    pid = convert_to_recid(pid, pid_type)
     identity = get_identity_for_user(user)
     service = RecordService()
-    recids = [
-        dm.json["id"]
-        for dm in DraftMetadata.query.all()
-        if dm is not None and dm.json is not None
-    ]
+    metadata = json.load(metadata_file)
 
-    for recid in recids:
-        try:
-            draft = service.read_draft(id_=recid, identity=identity)
-            click.secho(
-                "{} - {}".format(draft.id, draft.data["metadata"]["title"]), fg="green"
-            )
-        except:
-            pass
+    if patch:
+        draft_data = service.read_draft(id_=pid, identity=identity).data.copy()
+        metadata = patch_metadata(draft_data, metadata)
 
+    service.update_draft(id_=pid, identity=identity, data=metadata)
+    click.secho(pid, fg="green")
 
-@draft.command("delete")
+
+@draft.command("publish")
 @option_pid_value
 @option_pid_type
 @option_as_user
 @with_appcontext
 def publish_draft(pid, pid_type, user):
-    """Delete the specified draft."""
+    """Publish the specified draft."""
     pid = convert_to_recid(pid, pid_type)
     identity = get_identity_for_user(user)
     service = RecordService()
-    service.delete_draft(id_=pid, identity=identity)
+    service.publish(id_=pid, identity=identity)
     click.secho(pid, fg="green")
 
 
-@draft.command("publish")
+@draft.command("delete")
 @option_pid_value
 @option_pid_type
 @option_as_user
 @with_appcontext
-def publish_draft(pid, pid_type, user):
-    """Publish the specified draft."""
+def delete_draft(pid, pid_type, user):
+    """Delete the specified draft."""
     pid = convert_to_recid(pid, pid_type)
     identity = get_identity_for_user(user)
     service = RecordService()
-    service.publish(id_=pid, identity=identity)
-    click.secho(pid, fg="green")
+    service.delete_draft(id_=pid, identity=identity)
+    click.secho(pid, fg="red")
+
+
+@draft.group()
+def files():
+    """Manage files deposited with the draft."""
+    pass
 
 
 @files.command("add")
@@ -240,19 +226,14 @@ def add_files(filepaths, pid, pid_type, user):
     for file_path in filepaths:
         if isdir(file_path):
             # add all files (no recursion into sub-dirs) from the directory
-            dir_contents = os.listdir(file_path)
-            file_names = [
-                basename(fn) for fn in dir_contents if isfile(join(file_path, fn))
-            ]
-            if len(dir_contents) != len(file_names):
-                ignored = [
-                    basename(fn)
-                    for fn in dir_contents
-                    if not isfile(join(file_path, fn))
-                ]
-                click.secho(
-                    "ignored in '{}': {}".format(file_path, ignored), fg="red", err=True
-                )
+            exists = lambda fn: isfile(join(file_path, fn))
+            content = os.listdir(file_path)
+            file_names = [basename(fn) for fn in content if exists(fn)]
+
+            if len(content) != len(file_names):
+                ignored = [basename(fn) for fn in content if not exists(fn)]
+                msg = "ignored in '{}': {}".format(file_path, ignored)
+                click.secho(msg, fg="red", err=True)
 
             paths_ = [join(file_path, fn) for fn in file_names]
             paths.extend(paths_)
@@ -270,7 +251,7 @@ def add_files(filepaths, pid, pid_type, user):
     )
     for fp in paths:
         fn = basename(fp)
-        with open(file_path, "rb") as deposit_file:
+        with open(fp, "rb") as deposit_file:
             service.set_file_content(
                 id_=recid, file_key=fn, identity=identity, stream=deposit_file
             )
@@ -307,4 +288,4 @@ def list_files(pid, pid_type, user):
     service = DraftFileService()
     file_results = service.list_files(id_=recid, identity=identity)
     for f in file_results.entries:
-        click.echo(f)
+        click.echo("{}: {}".format(f["key"], f))
diff --git a/invenio_utilities_tuw/cli/users.py b/invenio_utilities_tuw/cli/users.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7f15eb2e568d6c16bcd6bfaeae3d8806fbbacb0
--- /dev/null
+++ b/invenio_utilities_tuw/cli/users.py
@@ -0,0 +1,41 @@
+"""Management commands for users."""
+
+import click
+from flask.cli import with_appcontext
+from invenio_accounts.models import User
+
+
+@click.group()
+def users():
+    """Management commands for users."""
+    pass
+
+
+@users.command("list")
+@click.option(
+    "--only-active/--include-inactive",
+    "-a/-A",
+    default=True,
+    help="show only active users, or list all users",
+)
+@click.option(
+    "--show-roles/--hide-roles",
+    "-r/-R",
+    default=False,
+    help="show or hide the roles associated with the users",
+)
+@with_appcontext
+def list_users(only_active, show_roles):
+    """List registered users."""
+    users = User.query
+
+    if only_active:
+        users = users.filter_by(active=True)
+
+    for user in users:
+        line = "{} {}".format(user.id, user.email)
+        if show_roles:
+            line += " {}".format([r.name for r in user.roles])
+
+        fg = "green" if user.active else "red"
+        click.secho(line, fg=fg)
diff --git a/invenio_utilities_tuw/cli/utils.py b/invenio_utilities_tuw/cli/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..9434c9545aaa4fb7a98e782e6c3a6c5b1d6e2c28
--- /dev/null
+++ b/invenio_utilities_tuw/cli/utils.py
@@ -0,0 +1,72 @@
+"""Utilities for the CLI commands."""
+
+import json
+
+from flask_principal import Identity
+from invenio_access import any_user
+from invenio_access.utils import get_identity
+from invenio_accounts import current_accounts
+from invenio_pidstore.models import PersistentIdentifier
+from invenio_rdm_records.services.services import (
+    BibliographicRecordService as RecordService,
+)
+
+
+def create_record_from_metadata(metadata_file_path, identity):
+    """Create a draft from the metadata in the specified JSON file."""
+    metadata = None
+    with open(metadata_file_path, "r") as metadata_file:
+        metadata = json.load(metadata_file)
+
+    if metadata is None:
+        raise Exception("not a valid json file: %s" % metadata_file_path)
+
+    service = RecordService()
+    draft = service.create(identity=identity, data=metadata)
+    return draft
+
+
+def patch_metadata(metadata: dict, patch: dict) -> dict:
+    """Replace the fields mentioned in the patch, while leaving others as is.
+
+    The first argument's content will be changed during the process.
+    """
+    for key in patch.keys():
+        val = patch[key]
+        if isinstance(val, dict):
+            patch_metadata(metadata[key], val)
+        else:
+            metadata[key] = val
+
+    return metadata
+
+
+def get_identity_for_user(user):
+    """Get the Identity for the user specified via email or ID."""
+    identity = None
+    if user is not None:
+        # note: this seems like the canonical way to go
+        #       'as_user' can be either an integer (id) or email address
+        u = current_accounts.datastore.get_user(user)
+        if u is not None:
+            identity = get_identity(u)
+        else:
+            raise LookupError("user not found: %s" % user)
+
+    if identity is None:
+        identity = Identity(1)
+        identity.provides.add(any_user)
+
+    return identity
+
+
+def convert_to_recid(pid_value, pid_type):
+    """Fetch the recid of the referenced object."""
+    if pid_type != "recid":
+        pid_value = (
+            PersistentIdentifier.query.filter_by(pid_value=pid_value, pid_type=pid_type)
+            .first()
+            .pid_value
+        )
+
+    return pid_value