diff --git a/clldutils/clilib.py b/clldutils/clilib.py
index f2851d4..3df3c0d 100644
--- a/clldutils/clilib.py
+++ b/clldutils/clilib.py
@@ -10,17 +10,53 @@ class ParserError(Exception):
     pass
 
 
+# Global registry for commands.
+# Note: This registry is global so it can only be used for one ArgumentParser instance.
+# Otherwise, different ArgumentParsers will share the same sub-commands which will rarely
+# be intended.
+_COMMANDS = []
+
+
+class Command(object):
+    def __init__(self, func, name=None):
+        self.func = func
+        self.name = name
+
+    @property
+    def doc(self):
+        return self.func.__doc__
+
+    def __call__(self, args):
+        return self.func(args)
+
+
+def command(name=None):
+    def wrap(f):
+        _COMMANDS.append(Command(f, name=name))
+        return f
+    return wrap
+
+
+def _attr(obj, attr):
+    return getattr(obj, attr, getattr(obj, '__{0}__'.format(attr), None))
+
+
 class ArgumentParser(argparse.ArgumentParser):
     """
-    An command line argument parser supporting sub-commands in a simple way.
+    A command line argument parser supporting sub-commands in a simple way.
+
+    Sub-commands can be registered in one of two ways:
+    - Passing functions as positional arguments into `ArgumentParser.__init__`.
+    - Decorating functions with the `command` decorator.
     """
     def __init__(self, pkg_name, *commands, **kw):
+        commands = commands or _COMMANDS
         kw.setdefault(
             'description', "Main command line interface of the %s package." % pkg_name)
         kw.setdefault(
             'epilog', "Use '%(prog)s help <cmd>' to get help about individual commands.")
         argparse.ArgumentParser.__init__(self, **kw)
-        self.commands = OrderedDict([(cmd.__name__, cmd) for cmd in commands])
+        self.commands = OrderedDict([(_attr(cmd, 'name'), cmd) for cmd in commands])
         self.pkg_name = pkg_name
         self.add_argument("--verbosity", help="increase output verbosity")
         self.add_argument('command', help='|'.join(self.commands.keys()))
@@ -31,7 +67,7 @@ class ArgumentParser(argparse.ArgumentParser):
         if args.command == 'help' and len(args.args):
             # As help text for individual commands we simply re-use the docstrings of the
             # callables registered for the command:
-            print(self.commands[args.args[0]].__doc__)
+            print(_attr(self.commands[args.args[0]], 'doc'))
         else:
             if args.command not in self.commands:
                 print('invalid command')
@@ -41,7 +77,7 @@ class ArgumentParser(argparse.ArgumentParser):
                 self.commands[args.command](args)
             except ParserError as e:
                 print(e)
-                print(self.commands[args.command].__doc__)
+                print(_attr(self.commands[args.command], 'doc'))
                 return 64
             except Exception as e:
                 if catch_all:

`clilib` allow custom names for sub-commands passed to ArgumentParser
Right now, subcommand names for `clilib.ArgumentParser` can only be function names. Thus, reserved words like `class` are impossible and names like `list` would cause shadowing of built-ins where the function is defined. So there should be a way to register callables as commands under custom names.
diff --git a/clldutils/tests/test_clilib.py b/clldutils/tests/test_clilib.py
index cf96215..f82f329 100644
--- a/clldutils/tests/test_clilib.py
+++ b/clldutils/tests/test_clilib.py
@@ -9,7 +9,7 @@ from clldutils.testing import capture, capture_all
 
 class Tests(TestCase):
     def test_ArgumentParser(self):
-        from clldutils.clilib import ArgumentParser, ParserError
+        from clldutils.clilib import ArgumentParser, ParserError, command
 
         def cmd(args):
             """
@@ -36,6 +36,19 @@ class Tests(TestCase):
             self.assertNotEqual(res[0], 0)
             self.assertTrue(res[1].startswith('invalid'))
 
+        @command(name='list')
+        def f(args):
+            """
+            my name is list
+            """
+            return
+
+        parser = ArgumentParser('pkg')
+        with capture(parser.main, args=['help', 'list']) as out:
+            self.assertIn('my name is list', out)
+
+        self.assertEqual(parser.main(args=['list', 'arg']), 0)
+
     def test_cmd_error(self):
         from clldutils.clilib import ArgumentParser
 

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest mock>=2.0
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/clld/clldutils.git@e1655578301e446ca5ec645b1ce1407dd083b56d#egg=clldutils
configparser==7.2.0
exceptiongroup==1.2.2
iniconfig==2.1.0
mock==5.2.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
python-dateutil==2.9.0.post0
six==1.17.0
tabulate==0.9.0
tomli==2.2.1

name: clldutils
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - configparser==7.2.0
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - mock==5.2.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - six==1.17.0
      - tabulate==0.9.0
      - tomli==2.2.1
prefix: /opt/conda/envs/clldutils

clldutils/tests/test_clilib.py::Tests::test_ArgumentParser
clldutils/tests/test_clilib.py::Tests::test_cmd_error clldutils/tests/test_clilib.py::Tests::test_confirm
clldutils/clilib.py
clldutils/clilib.py