codecov[bot]: # [Codecov](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=h1) Report
> Merging [#514](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee/commit/2f3dc41627f5fdd4546c5c4f2f471c59cbfc8ae5&el=desc) will **decrease** coverage by `3.69%`.
> The diff coverage is `97.22%`.

[![Impacted file tree graph](https://codecov.io/gh/repobee/repobee/pull/514/graphs/tree.svg?width=650&height=150&src=pr&token=5vOKgkphZe)](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #514      +/-   ##
==========================================
- Coverage   91.86%   88.17%   -3.70%     
==========================================
  Files          43       43              
  Lines        2520     2494      -26     
  Branches      410      409       -1     
==========================================
- Hits         2315     2199     -116     
- Misses        162      249      +87     
- Partials       43       46       +3     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `93.18% <83.33%> (-0.08%)` | :arrow_down: |
| [src/\_repobee/ext/defaults/configwizard.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9jb25maWd3aXphcmQucHk=) | `97.29% <100.00%> (ø)` | |
| [src/repobee\_plug/cli.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkucHk=) | `94.61% <100.00%> (-0.49%)` | :arrow_down: |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9naXRsYWIucHk=) | `63.86% <0.00%> (-28.56%)` | :arrow_down: |
| [src/\_repobee/cli/parsing.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9wYXJzaW5nLnB5) | `87.57% <0.00%> (-4.35%)` | :arrow_down: |
| [src/\_repobee/git.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2dpdC5weQ==) | `96.62% <0.00%> (-3.38%)` | :arrow_down: |
| [src/\_repobee/command/repos.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NvbW1hbmQvcmVwb3MucHk=) | `91.80% <0.00%> (-3.28%)` | :arrow_down: |
| [src/\_repobee/ext/query.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9xdWVyeS5weQ==) | `48.48% <0.00%> (-1.52%)` | :arrow_down: |
| [src/\_repobee/ext/defaults/github.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9naXRodWIucHk=) | `90.29% <0.00%> (-0.11%)` | :arrow_down: |
| ... and [5 more](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=footer). Last update [2f3dc41...262d3bb](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

diff --git a/src/_repobee/ext/defaults/configwizard.py b/src/_repobee/ext/defaults/configwizard.py
index d1c0e3c..16707cc 100644
--- a/src/_repobee/ext/defaults/configwizard.py
+++ b/src/_repobee/ext/defaults/configwizard.py
@@ -20,7 +20,7 @@ LOGGER = daiquiri.getLogger(__file__)
 
 
 class Wizard(plug.Plugin, plug.cli.Command):
-    __settings__ = plug.cli.CommandSettings(
+    __settings__ = plug.cli.command_settings(
         category=plug.cli.CoreCommand.config,
         help="Interactive configuration wizard to set up the config file.",
         description=(
diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index d07ef9c..ce6bd42 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -95,9 +95,7 @@ class _PluginMeta(type):
 
 def _extract_cli_options(
     attrdict,
-) -> List[
-    Tuple[str, Union[cli.Option, cli.Positional, cli.MutuallyExclusiveGroup]]
-]:
+) -> List[Tuple[str, Union[cli._Option, cli._MutuallyExclusiveGroup]]]:
     """Return any members that are CLI options as a list of tuples on the form
     (member_name, option). Other types of CLI arguments, such as positionals,
     are converted to :py:class:`~cli.Option`s.
@@ -105,9 +103,7 @@ def _extract_cli_options(
     return [
         (key, value)
         for key, value in attrdict.items()
-        if isinstance(
-            value, (cli.Option, cli.Positional, cli.MutuallyExclusiveGroup)
-        )
+        if isinstance(value, (cli._Option, cli._MutuallyExclusiveGroup))
     ]
 
 
@@ -160,7 +156,7 @@ def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
     """
 
     def create_extension_command(self):
-        settings = attrdict.get("__settings__") or cli.CommandSettings()
+        settings = attrdict.get("__settings__") or cli.command_settings()
         if settings.config_section_name:
             self.__config_section__ = settings.config_section_name
 
@@ -181,13 +177,13 @@ def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
 
 def _add_option(
     name: str,
-    opt: Union[cli.Positional, cli.Option, cli.MutuallyExclusiveGroup],
+    opt: Union[cli._Option, cli._MutuallyExclusiveGroup],
     configured_value: str,
     show_all_opts: bool,
     parser: argparse.ArgumentParser,
 ) -> None:
     """Add an option to the parser based on the cli option."""
-    if isinstance(opt, cli.MutuallyExclusiveGroup):
+    if isinstance(opt, cli._MutuallyExclusiveGroup):
         mutex_parser = parser.add_mutually_exclusive_group(
             required=opt.required
         )
@@ -201,8 +197,9 @@ def _add_option(
             )
         return
 
+    assert isinstance(opt, cli._Option)
     args = []
-    kwargs = opt.argparse_kwargs or {}
+    kwargs = opt.argparse_kwargs
 
     if opt.converter:
         kwargs["type"] = opt.converter
@@ -213,7 +210,7 @@ def _add_option(
         else opt.help or ""
     )
 
-    if isinstance(opt, cli.Option):
+    if opt.argument_type == cli.ArgumentType.OPTION:
         if opt.short_name:
             args.append(opt.short_name)
 
@@ -227,7 +224,7 @@ def _add_option(
         kwargs["default"] = configured_value or opt.default
         # required opts become not required if configured
         kwargs["required"] = not configured_value and opt.required
-    elif isinstance(opt, cli.Positional):
+    elif opt.argument_type == cli.ArgumentType.POSITIONAL:
         args.append(name)
 
     parser.add_argument(*args, **kwargs)
diff --git a/src/repobee_plug/cli.py b/src/repobee_plug/cli.py
index 8918837..99aa139 100644
--- a/src/repobee_plug/cli.py
+++ b/src/repobee_plug/cli.py
@@ -1,26 +1,36 @@
 """Plugin functionality for creating extensions to the RepoBee CLI."""
 import abc
 import collections
+import enum
 import itertools
-from typing import List, Tuple, Optional, Set, Mapping, Iterable
+from typing import List, Tuple, Optional, Set, Mapping, Iterable, Any, Callable
 
 from repobee_plug import _containers
 
+
 __all__ = [
-    "Option",
-    "Positional",
-    "MutuallyExclusiveGroup",
+    "option",
+    "positional",
+    "mutually_exclusive_group",
+    "command_settings",
+    "command_extension_settings",
     "Command",
     "CommandExtension",
     "Action",
     "Category",
     "CoreCommand",
-    "CommandSettings",
 ]
 
 from repobee_plug._containers import ImmutableMixin
 
-Option = collections.namedtuple(
+
+class ArgumentType(enum.Enum):
+    OPTION = "Option"
+    POSITIONAL = "Positional"
+    MUTEX_GROUP = "Mutex"
+
+
+_Option = collections.namedtuple(
     "Option",
     [
         "short_name",
@@ -30,20 +40,41 @@ Option = collections.namedtuple(
         "converter",
         "required",
         "default",
+        "argument_type",
         "argparse_kwargs",
     ],
 )
-Option.__new__.__defaults__ = (None,) * len(Option._fields)
+_Option.__new__.__defaults__ = (None,) * len(_Option._fields)
 
-Positional = collections.namedtuple(
-    "Positional", ["help", "converter", "argparse_kwargs"]
+_CommandSettings = collections.namedtuple(
+    "_CommandSettings",
+    [
+        "action_name",
+        "category",
+        "help",
+        "description",
+        "requires_api",
+        "base_parsers",
+        "config_section_name",
+    ],
 )
-Positional.__new__.__defaults__ = (None,) * len(Positional._fields)
 
 
-class CommandSettings(_containers.ImmutableMixin):
-    """Settings for a :py:class:`Command`, that can be provided in the
-    ``__settings__`` attribute.
+_CommandExtensionSettings = collections.namedtuple(
+    "_CommandExtensionSettings", ["actions", "config_section_name"]
+)
+
+
+def command_settings(
+    action_name: Optional[str] = None,
+    category: Optional["CoreCommand"] = None,
+    help: str = "",
+    description: str = "",
+    requires_api: bool = False,
+    base_parsers: Optional[List[_containers.BaseParser]] = None,
+    config_section_name: Optional[str] = None,
+) -> _CommandSettings:
+    """Create a settings object for a :py:class:`Command`.
 
     Example usage:
 
@@ -53,7 +84,7 @@ class CommandSettings(_containers.ImmutableMixin):
         import repobee_plug as plug
 
         class Ext(plug.Plugin, plug.cli.Command):
-            __settings__ = plug.cli.CommandSettings(
+            __settings__ = plug.cli.command_settings(
                 action_name="hello",
                 category=plug.cli.CoreCommand.config,
             )
@@ -67,116 +98,207 @@ class CommandSettings(_containers.ImmutableMixin):
 
         $ repobee -p ext.py config hello
         Hello, world!
+
+    Args:
+        action_name: The name of the action that the command will be
+            available under. Defaults to the name of the plugin class.
+        category: The category to place this command in. If not specified,
+            then the command will be top-level (i.e. uncategorized).
+        help: A help section for the command. This appears when listing the
+            help section of the command's category.
+        description: A help section for the command. This appears when
+            listing the help section for the command itself.
+        requires_api: If True, a platform API will be insantiated and
+            passed to the command function.
+        base_parsers: A list of base parsers to add to the command.
+        config_section_name: The name of the configuration section the
+            command should look for configurable options in. Defaults
+            to the name of the plugin the command is defined in.
+    """
+    return _CommandSettings(
+        action_name=action_name,
+        category=category,
+        help=help,
+        description=description,
+        requires_api=requires_api,
+        base_parsers=base_parsers,
+        config_section_name=config_section_name,
+    )
+
+
+def command_extension_settings(
+    actions: List["Action"], config_section_name: Optional[str] = None
+) -> _CommandExtensionSettings:
+    """Settings for a :py:class:`CommandExtension`.
+
+    Args:
+        actions: A list of actions to extend.
+        config_section_name: Name of the configuration section that the
+            command extension will fetch configuration values from.
+            Defaults to the name of the plugin in which the extension is
+            defined.
+    Returns:
+        A wrapper object for settings.
     """
 
-    action_name: Optional[str]
-    category: Optional["CoreCommand"]
-    help: str
-    description: str
-    requires_api: bool
-    base_parsers: Optional[List[_containers.BaseParser]]
-    config_section_name: Optional[str]
-
-    def __init__(
-        self,
-        action_name: Optional[str] = None,
-        category: Optional["CoreCommand"] = None,
-        help: str = "",
-        description: str = "",
-        requires_api: bool = False,
-        base_parsers: Optional[List[_containers.BaseParser]] = None,
-        config_section_name: Optional[str] = None,
-    ):
-        """
-        Args:
-            action_name: The name of the action that the command will be
-                available under. Defaults to the name of the plugin class.
-            category: The category to place this command in. If not specified,
-                then the command will be top-level (i.e. uncategorized).
-            help: A help section for the command. This appears when listing the
-                help section of the command's category.
-            description: A help section for the command. This appears when
-                listing the help section for the command itself.
-            requires_api: If True, a platform API will be insantiated and
-                passed to the command function.
-            base_parsers: A list of base parsers to add to the command.
-            config_section_name: The name of the configuration section the
-                command should look for configurable options in. Defaults
-                to the name of the plugin the command is defined in.
-        """
-        object.__setattr__(self, "action_name", action_name)
-        object.__setattr__(self, "category", category)
-        object.__setattr__(self, "help", help)
-        object.__setattr__(self, "description", description)
-        object.__setattr__(self, "requires_api", requires_api)
-        object.__setattr__(self, "base_parsers", base_parsers)
-        object.__setattr__(self, "config_section_name", config_section_name)
+    if not actions:
+        raise ValueError(
+            f"argument 'actions' must be a non-empty list: {actions}"
+        )
+    return _CommandExtensionSettings(
+        actions=actions, config_section_name=config_section_name
+    )
+
+
+def option(
+    short_name: Optional[str] = None,
+    long_name: Optional[str] = None,
+    help: str = "",
+    required: bool = False,
+    default: Optional[Any] = None,
+    configurable: bool = False,
+    converter: Optional[Callable[[str], Any]] = None,
+    argparse_kwargs: Optional[Mapping[str, Any]] = None,
+):
+    """Create an option for a :py:class:`Command` or a
+    :py:class:`CommandExtension`.
 
+    Example usage:
 
-class CommandExtensionSettings(_containers.ImmutableMixin):
-    """Settings for a :py:class:`CommandExtension`."""
+    .. code-block:: python
+        :caption: ext.py
 
-    actions: List["Action"]
-    config_section_name: Optional[str]
+        import repobee_plug as plug
 
-    def __init__(
-        self,
-        actions: List["Action"],
-        config_section_name: Optional[str] = None,
-    ):
-        """
-        Args:
-            actions: A list of actions to extend.
-            config_section_name: Name of the configuration section that the
-                command extension will fetch configuration values from.
-                Defaults to the name of the plugin in which the extension is
-                defined.
-        """
-        if not actions:
-            raise ValueError(
-                f"argument 'actions' must be a non-empty list: {actions}"
-            )
-        object.__setattr__(self, "actions", actions)
-        object.__setattr__(self, "config_section_name", config_section_name)
 
+        class Hello(plug.Plugin, plug.cli.Command):
+            name = plug.cli.option(help="Your name.")
+            age = plug.cli.option(converter=int, help="Your age.")
 
-class MutuallyExclusiveGroup(_containers.ImmutableMixin):
-    """A group of mutually exclusive CLI options.
+            def command_callback(self, args, api):
+                print(
+                    f"Hello, my name is {args.name} "
+                    f"and I am {args.age} years old"
+                )
 
-    Attributes:
-        ALLOWED_TYPES: The types that are allowed to be passed in the
-            constructor kwargs.
-        options: The options that have been stored in this group.
-        required: Whether or not this mutex group is required.
+    This command can then be called like so:
+
+    .. code-block:: bash
+
+        $ repobee -p ext.py hello --name Alice --age 22
+        Hello, my name is Alice and I am 22 years old
+
+    Args:
+        short_name: The short name of this option. Must start with ``-``.
+        long_name: The long name of this option. Must start with `--`.
+        help: A description of this option that is used in the CLI help
+            section.
+        required: Whether or not this option is required.
+        default: A default value for this option.
+        configurable: Whether or not this option is configurable. If an option
+            is both configurable and required, having a value for the option
+            in the configuration file makes the option non-required.
+        converter: A converter function that takes a string and returns
+            the argument in its proper state. Should also perform input
+            validation and raise an error if the input is malformed.
+        argparse_kwargs: Keyword arguments that are passed directly to
+            :py:meth:`argparse.ArgumentParser.add_argument`
+    Returns:
+        A CLI argument wrapper used internally by RepoBee to create command
+        line arguments.
     """
 
-    ALLOWED_TYPES = (Option,)
-    options: List[Tuple[str, Option]]
-    required: bool
+    return _Option(
+        short_name=short_name,
+        long_name=long_name,
+        configurable=configurable,
+        help=help,
+        converter=converter,
+        required=required,
+        default=default,
+        argument_type=ArgumentType.OPTION,
+        argparse_kwargs=argparse_kwargs or {},
+    )
+
+
+def positional(
+    help: str = "",
+    converter: Optional[Callable[[str], Any]] = None,
+    argparse_kwargs: Optional[Mapping[str, Any]] = None,
+) -> _Option:
+    """Create a positional argument for a :py:class:`Command` or a
+    :py:class:`CommandExtension`.
+
+    Example usage:
+
+    .. code-block:: python
+        :caption: ext.py
+
+        import repobee_plug as plug
 
-    def __init__(self, *, __required__: bool = False, **kwargs):
-        """
-        Args:
-            __required__: Whether or not this mutex group is required.
-            kwargs: Keyword arguments on the form ``name=plug.cli.Option()``.
-        """
 
-        def _check_type(key, value):
-            if not isinstance(value, self.ALLOWED_TYPES):
-                raise TypeError(
-                    f"{value.__class__.__name__} "
-                    f"not allowed in mutex group: {key}={value}"
+        class Hello(plug.Plugin, plug.cli.Command):
+            name = plug.cli.Positional(help="Your name.")
+            age = plug.cli.Positional(converter=int, help="Your age.")
+
+            def command_callback(self, args, api):
+                print(
+                    f"Hello, my name is {args.name} "
+                    f"and I am {args.age} years old"
                 )
-            return True
 
-        options = [
-            (key, value)
-            for key, value in kwargs.items()
-            if _check_type(key, value)
-        ]
+    This command can then be called like so:
 
-        object.__setattr__(self, "options", options)
-        object.__setattr__(self, "required", __required__)
+    .. code-block:: bash
+
+        $ repobee -p ext.py hello Alice 22
+        Hello, my name is Alice and I am 22 years old
+
+    Args:
+        help: The help section for the positional argument.
+        converter: A converter function that takes a string and returns
+            the argument in its proper state. Should also perform input
+            validation and raise an error if the input is malformed.
+        argparse_kwargs: Keyword arguments that are passed directly to
+            :py:meth:`argparse.ArgumentParser.add_argument`
+    Returns:
+        A CLI argument wrapper used internally by RepoBee to create command
+        line argument.
+    """
+    return _Option(
+        help=help,
+        converter=converter,
+        argparse_kwargs=argparse_kwargs or {},
+        argument_type=ArgumentType.POSITIONAL,
+    )
+
+
+def mutually_exclusive_group(*, __required__: bool = False, **kwargs):
+    """
+    Args:
+        __required__: Whether or not this mutex group is required.
+        kwargs: Keyword arguments on the form ``name=plug.cli.option()``.
+    """
+    allowed_types = (ArgumentType.OPTION,)
+
+    def _check_arg_type(name, opt):
+        if opt.argument_type not in allowed_types:
+            raise ValueError(
+                f"{opt.argument_type.value} not allowed in mutex group"
+            )
+        return True
+
+    options = [
+        (key, value)
+        for key, value in kwargs.items()
+        if _check_arg_type(key, value)
+    ]
+    return _MutuallyExclusiveGroup(required=__required__, options=options)
+
+
+_MutuallyExclusiveGroup = collections.namedtuple(
+    "_MutuallyExclusiveGroup", ["options", "required"]
+)
 
 
 class CommandExtension:
@@ -220,10 +342,10 @@ class Command:
 
         class Greeting(plug.Plugin, plug.cli.Command):
 
-            name = plug.cli.Option(
+            name = plug.cli.option(
                 short_name="-n", help="your name", required=True
             )
-            age = plug.cli.Option(
+            age = plug.cli.option(
                 converter=int, help="your age", default=30
             )
 

Refactor Option, Positional and MutuallyExclusiveGroup to be functions
If these are rewritten as functions, the user doesn't need to care much which data structure is used, and we can put extra information into them.
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 0d769a7..75f599d 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -120,8 +120,8 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(help="your name", required=True)
-            age = plug.cli.Option(converter=int, help="your age", default=30)
+            name = plug.cli.option(help="your name", required=True)
+            age = plug.cli.option(converter=int, help="your age", default=30)
 
             def command_callback(self, args, api):
                 print(f"My name is {args.name} and I am {args.age} years old")
@@ -157,7 +157,7 @@ class TestDeclarativeExtensionCommand:
         expected_requires_api = True
 
         class ExtCommand(plug.Plugin, plug.cli.Command):
-            __settings__ = plug.cli.CommandSettings(
+            __settings__ = plug.cli.command_settings(
                 category=expected_category,
                 action_name=expected_name,
                 help=expected_help,
@@ -194,7 +194,7 @@ class TestDeclarativeExtensionCommand:
         """Test configuring a default value for an option."""
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(
+            name = plug.cli.option(
                 help="Your name.", required=True, configurable=True
             )
 
@@ -218,7 +218,7 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(help="Your name.", required=True)
+            name = plug.cli.option(help="Your name.", required=True)
 
             def command_callback(self, args, api):
                 pass
@@ -246,7 +246,7 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(
+            name = plug.cli.option(
                 short_name="-n",
                 long_name="--your-name",
                 help="your name",
@@ -269,8 +269,8 @@ class TestDeclarativeExtensionCommand:
 
     def test_positional_arguments(self):
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Positional()
-            age = plug.cli.Positional(converter=int)
+            name = plug.cli.positional()
+            age = plug.cli.positional(converter=int)
 
             def command_callback(self, args, api):
                 pass
@@ -292,10 +292,10 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            age_mutex = plug.cli.MutuallyExclusiveGroup(
-                age=plug.cli.Option(converter=int),
-                old=plug.cli.Option(
-                    argparse_kwargs=dict(action="store_const", const=1337),
+            age_mutex = plug.cli.mutually_exclusive_group(
+                age=plug.cli.option(converter=int),
+                old=plug.cli.option(
+                    argparse_kwargs=dict(action="store_const", const=1337)
                 ),
                 __required__=True,
             )
@@ -318,11 +318,11 @@ class TestDeclarativeExtensionCommand:
     def test_positionals_not_allowed_in_mutex_group(self):
         """Positional arguments don't make sense in a mutex group."""
 
-        with pytest.raises(TypeError) as exc_info:
-            plug.cli.MutuallyExclusiveGroup(
-                age=plug.cli.Positional(converter=int),
-                old=plug.cli.Option(
-                    argparse_kwargs=dict(action="store_const", const=1337),
+        with pytest.raises(ValueError) as exc_info:
+            plug.cli.mutually_exclusive_group(
+                age=plug.cli.positional(converter=int),
+                old=plug.cli.option(
+                    argparse_kwargs=dict(action="store_const", const=1337)
                 ),
                 __required__=True,
             )
@@ -334,10 +334,10 @@ class TestDeclarativeExtensionCommand:
         old = 1337
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            age_mutex = plug.cli.MutuallyExclusiveGroup(
-                age=plug.cli.Option(converter=int),
-                old=plug.cli.Option(
-                    argparse_kwargs=dict(action="store_const", const=old),
+            age_mutex = plug.cli.mutually_exclusive_group(
+                age=plug.cli.option(converter=int),
+                old=plug.cli.option(
+                    argparse_kwargs=dict(action="store_const", const=old)
                 ),
                 __required__=True,
             )
@@ -369,11 +369,11 @@ class TestDeclarativeCommandExtension:
         """Tests adding a required option to ``config show``."""
 
         class ConfigShowExt(plug.Plugin, plug.cli.CommandExtension):
-            __settings__ = plug.cli.CommandExtensionSettings(
+            __settings__ = plug.cli.command_extension_settings(
                 actions=[plug.cli.CoreCommand.config.show]
             )
 
-            silly_new_option = plug.cli.Option(help="your name", required=True)
+            silly_new_option = plug.cli.option(help="your name", required=True)
 
         with pytest.raises(SystemExit):
             repobee.run(
@@ -423,7 +423,7 @@ class TestDeclarativeCommandExtension:
         with pytest.raises(ValueError) as exc_info:
 
             class Ext(plug.Plugin, plug.cli.CommandExtension):
-                __settings__ = plug.cli.CommandExtensionSettings(actions=[])
+                __settings__ = plug.cli.command_extension_settings(actions=[])
 
         assert "argument 'actions' must be a non-empty list" in str(
             exc_info.value

pip install -e .[TEST]
parse_log_pytest
requirements.txt
pytest>=4.0.0 pytest-cov>=2.6.1 pytest-mock codecov bandit flake8 black pytest
apt-get update apt-get install -y gcc
3.8
requirements/app.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
appdirs==1.4.4
bandit==1.7.10
black==24.8.0
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8
codecov==2.1.13
colored==1.4.4
coverage==7.6.1
cryptography==44.0.2
daiquiri==3.2.5.1
Deprecated==1.2.18
exceptiongroup==1.2.2
flake8==7.1.2
idna==3.10
iniconfig==2.1.0
markdown-it-py==3.0.0
mccabe==0.7.0
mdurl==0.1.2
mypy-extensions==1.0.0
packaging==24.2
pathspec==0.12.1
pbr==6.1.1
platformdirs==4.3.6
pluggy==1.5.0
pycodestyle==2.12.1
pycparser==2.22
pyflakes==3.2.0
PyGithub==2.6.1
Pygments==2.19.1
PyJWT==2.9.0
PyNaCl==1.5.0
pytest==8.3.5
pytest-cov==5.0.0
pytest-mock==3.14.0
python-gitlab==1.15.0
python-json-logger==3.3.0
PyYAML==6.0.2
-e git+https://github.com/repobee/repobee.git@2f3dc41627f5fdd4546c5c4f2f471c59cbfc8ae5#egg=repobee
requests==2.32.3
rich==14.0.0
six==1.17.0
stevedore==5.3.0
tomli==2.2.1
typing_extensions==4.13.0
urllib3==2.2.3
wrapt==1.17.2

name: repobee
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=24.2=py38h06a4308_0
  - python=3.8.20=he870216_0
  - readline=8.2=h5eee18b_0
  - setuptools=75.1.0=py38h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.44.0=py38h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - appdirs==1.4.4
      - bandit==1.7.10
      - black==24.8.0
      - certifi==2025.1.31
      - cffi==1.17.1
      - charset-normalizer==3.4.1
      - click==8.1.8
      - codecov==2.1.13
      - colored==1.4.4
      - coverage==7.6.1
      - cryptography==44.0.2
      - daiquiri==3.2.5.1
      - deprecated==1.2.18
      - exceptiongroup==1.2.2
      - flake8==7.1.2
      - idna==3.10
      - iniconfig==2.1.0
      - markdown-it-py==3.0.0
      - mccabe==0.7.0
      - mdurl==0.1.2
      - mypy-extensions==1.0.0
      - packaging==24.2
      - pathspec==0.12.1
      - pbr==6.1.1
      - platformdirs==4.3.6
      - pluggy==1.5.0
      - pycodestyle==2.12.1
      - pycparser==2.22
      - pyflakes==3.2.0
      - pygithub==2.6.1
      - pygments==2.19.1
      - pyjwt==2.9.0
      - pynacl==1.5.0
      - pytest==8.3.5
      - pytest-cov==5.0.0
      - pytest-mock==3.14.0
      - python-gitlab==1.15.0
      - python-json-logger==3.3.0
      - pyyaml==6.0.2
      - repobee==3.0.0a2
      - requests==2.32.3
      - rich==14.0.0
      - six==1.17.0
      - stevedore==5.3.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - urllib3==2.2.3
      - wrapt==1.17.2
prefix: /opt/conda/envs/repobee

tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_defaults tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_with_settings tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_generated_parser tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_configuration tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_raises_when_non_configurable_value_is_configured tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_override_opt_names tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_positional_arguments tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_mutex_group_arguments_are_mutually_exclusive tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_positionals_not_allowed_in_mutex_group tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_mutex_group_allows_one_argument tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_add_required_option_to_config_show tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_requires_non_empty_actions_list
tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_raises_on_non_hook_public_method tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_happy_path tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_with_private_methods tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_raises_when_command_and_command_extension_are_subclassed_together tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_requires_settings
src/_repobee/ext/defaults/configwizard.py src/repobee_plug/_pluginmeta.py src/repobee_plug/cli.py
src/_repobee/ext/defaults/configwizard.py src/repobee_plug/_pluginmeta.py src/repobee_plug/cli.py