Repository: repobee/repobee

Issue:
Make it possible for plugins to add options to any of the core parsers
This is very much related to #490 . Like there, I'm not sure what the best way of doing this is. It's probably best if the plugins don't directly operate against the parser anymore.

A possible way of doing this would be to add a single hook function in which one can define additional options, and which parsers these options should be added to. This should be relatively straightforward after the improved CLI has been implemented as described in #485 , with the `category action` semantics.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/repobee/repobee/pull/504?src=pr&el=h1) Report
> Merging [#504](https://codecov.io/gh/repobee/repobee/pull/504?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee/commit/544463740c978f81fbb1b508b0cce54bc7fedead&el=desc) will **decrease** coverage by `0.01%`.
> The diff coverage is `93.24%`.

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

```diff
@@            Coverage Diff             @@
##           master     #504      +/-   ##
==========================================
- Coverage   92.04%   92.02%   -0.02%     
==========================================
  Files          43       43              
  Lines        2413     2445      +32     
  Branches      393      396       +3     
==========================================
+ Hits         2221     2250      +29     
- Misses        155      156       +1     
- Partials       37       39       +2     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/504?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/\_repobee/cli/mainparser.py](https://codecov.io/gh/repobee/repobee/pull/504/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9tYWlucGFyc2VyLnB5) | `94.79% <90.90%> (-1.78%)` | :arrow_down: |
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/504/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `98.66% <95.00%> (-1.34%)` | :arrow_down: |
| [src/repobee\_plug/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/504/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/repobee\_plug/\_containers.py](https://codecov.io/gh/repobee/repobee/pull/504/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fY29udGFpbmVycy5weQ==) | `93.12% <100.00%> (+1.56%)` | :arrow_up: |
| [src/repobee\_plug/cli.py](https://codecov.io/gh/repobee/repobee/pull/504/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/504?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/504?src=pr&el=footer). Last update [5444637...514e15d](https://codecov.io/gh/repobee/repobee/pull/504?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/_repobee/cli/mainparser.py b/src/_repobee/cli/mainparser.py
index 3490cdf..1a4989b 100644
--- a/src/_repobee/cli/mainparser.py
+++ b/src/_repobee/cli/mainparser.py
@@ -11,7 +11,7 @@ import argparse
 import pathlib
 
 import logging
-from typing import List, Optional
+from typing import List, Optional, Union, Mapping
 
 import daiquiri
 
@@ -167,7 +167,9 @@ def _add_subparsers(parser, show_all_opts, ext_commands, config_file):
 
     subparsers = parser.add_subparsers(dest=SUB)
     subparsers.required = True
-    categories = {}
+    parsers: Mapping[
+        Union[plug.Category, plug.Action], argparse.ArgumentParser
+    ] = {}
 
     def _create_category_parsers(category, help, description):
         category_command = subparsers.add_parser(
@@ -175,7 +177,7 @@ def _add_subparsers(parser, show_all_opts, ext_commands, config_file):
         )
         category_parsers = category_command.add_subparsers(dest=ACTION)
         category_parsers.required = True
-        categories[category] = category_parsers
+        parsers[category] = category_parsers
         return category_parsers
 
     repo_parsers = _create_category_parsers(
@@ -199,16 +201,32 @@ def _add_subparsers(parser, show_all_opts, ext_commands, config_file):
         description="Configure RepoBee.",
     )
 
+    def _add_action_parser(category_parsers):
+        def inner(action, **kwargs):
+            parsers[action] = category_parsers.add_parser(
+                action.name, **kwargs
+            )
+            return parsers[action]
+
+        return inner
+
     _add_repo_parsers(
-        base_parser, base_student_parser, master_org_parser, repo_parsers
+        base_parser,
+        base_student_parser,
+        master_org_parser,
+        _add_action_parser(repo_parsers),
     )
     _add_issue_parsers(
-        [base_parser, base_student_parser, _REPO_NAME_PARSER], issues_parsers
+        [base_parser, base_student_parser, _REPO_NAME_PARSER],
+        _add_action_parser(issues_parsers),
     )
     _add_peer_review_parsers(
-        [base_parser, base_student_parser, _REPO_NAME_PARSER], review_parsers
+        [base_parser, base_student_parser, _REPO_NAME_PARSER],
+        _add_action_parser(review_parsers),
+    )
+    _add_config_parsers(
+        base_parser, master_org_parser, _add_action_parser(config_parsers)
     )
-    _add_config_parsers(base_parser, master_org_parser, config_parsers)
 
     return _add_extension_parsers(
         subparsers,
@@ -217,17 +235,17 @@ def _add_subparsers(parser, show_all_opts, ext_commands, config_file):
         base_student_parser,
         master_org_parser,
         _REPO_NAME_PARSER,
-        categories,
+        parsers,
         config._read_config(config_file) if config_file.is_file() else {},
         show_all_opts,
     )
 
 
 def _add_repo_parsers(
-    base_parser, base_student_parser, master_org_parser, repo_parsers
+    base_parser, base_student_parser, master_org_parser, add_parser
 ):
-    repo_parsers.add_parser(
-        plug.CoreCommand.repos.setup.name,
+    add_parser(
+        plug.CoreCommand.repos.setup,
         help="Setup student repos.",
         description=(
             "Setup student repositories based on master repositories. "
@@ -248,8 +266,8 @@ def _add_repo_parsers(
         formatter_class=_OrderedFormatter,
     )
 
-    update = repo_parsers.add_parser(
-        plug.CoreCommand.repos.update.name,
+    update = add_parser(
+        plug.CoreCommand.repos.update,
         help="Update existing student repos.",
         description=(
             "Push changes from master repos to student repos. If the "
@@ -275,8 +293,8 @@ def _add_repo_parsers(
         type=str,
     )
 
-    clone = repo_parsers.add_parser(
-        plug.CoreCommand.repos.clone.name,
+    clone = add_parser(
+        plug.CoreCommand.repos.clone,
         help="Clone student repos.",
         description="Clone student repos asynchronously in bulk.",
         parents=[
@@ -293,8 +311,8 @@ def _add_repo_parsers(
 
     plug.manager.hook.clone_parser_hook(clone_parser=clone)
 
-    repo_parsers.add_parser(
-        plug.CoreCommand.repos.create_teams.name,
+    add_parser(
+        plug.CoreCommand.repos.create_teams,
         help="Create student teams without creating repos.",
         description=(
             "Only create student teams. This is intended for when you want to "
@@ -307,8 +325,8 @@ def _add_repo_parsers(
         formatter_class=_OrderedFormatter,
     )
 
-    repo_parsers.add_parser(
-        plug.CoreCommand.repos.migrate.name,
+    add_parser(
+        plug.CoreCommand.repos.migrate,
         help="Migrate repositories into the target organization.",
         description=(
             "Migrate repositories into the target organization. "
@@ -320,9 +338,9 @@ def _add_repo_parsers(
     )
 
 
-def _add_config_parsers(base_parser, master_org_parser, config_parsers):
-    show_config = config_parsers.add_parser(
-        plug.CoreCommand.config.show.name,
+def _add_config_parsers(base_parser, master_org_parser, add_parser):
+    show_config = add_parser(
+        plug.CoreCommand.config.show,
         help="Show the configuration file",
         description=(
             "Show the contents of the configuration file. If no configuration "
@@ -333,8 +351,8 @@ def _add_config_parsers(base_parser, master_org_parser, config_parsers):
     )
     _add_traceback_arg(show_config)
 
-    config_parsers.add_parser(
-        plug.CoreCommand.config.verify.name,
+    add_parser(
+        plug.CoreCommand.config.verify,
         help="Verify core settings.",
         description="Verify core settings by trying various API requests.",
         parents=[base_parser, master_org_parser],
@@ -342,9 +360,9 @@ def _add_config_parsers(base_parser, master_org_parser, config_parsers):
     )
 
 
-def _add_peer_review_parsers(base_parsers, review_parsers):
-    assign_parser = review_parsers.add_parser(
-        plug.CoreCommand.reviews.assign.name,
+def _add_peer_review_parsers(base_parsers, add_parser):
+    assign_parser = add_parser(
+        plug.CoreCommand.reviews.assign,
         description=(
             "For each student repo, create a review team with read access "
             "named <student-repo-name>-review and randomly assign "
@@ -379,8 +397,8 @@ def _add_peer_review_parsers(base_parsers, review_parsers):
         ),
         type=str,
     )
-    check_review_progress = review_parsers.add_parser(
-        plug.CoreCommand.reviews.check.name,
+    check_review_progress = add_parser(
+        plug.CoreCommand.reviews.check,
         description=(
             "Check which students have opened review review issues in their "
             "assigned repos. As it is possible for students to leave the peer "
@@ -414,8 +432,8 @@ def _add_peer_review_parsers(base_parsers, review_parsers):
         type=int,
         required=True,
     )
-    review_parsers.add_parser(
-        plug.CoreCommand.reviews.end.name,
+    add_parser(
+        plug.CoreCommand.reviews.end,
         description=(
             "Delete review allocations assigned with `assign-reviews`. "
             "This is a destructive action, as the allocations for reviews "
@@ -437,10 +455,10 @@ def _add_peer_review_parsers(base_parsers, review_parsers):
     )
 
 
-def _add_issue_parsers(base_parsers, issue_parsers):
+def _add_issue_parsers(base_parsers, add_parser):
     base_parser, base_student_parser, master_org_parser = base_parsers
-    open_parser = issue_parsers.add_parser(
-        plug.CoreCommand.issues.open.name,
+    open_parser = add_parser(
+        plug.CoreCommand.issues.open,
         description=(
             "Open issues in student repositories. For each master repository "
             "specified, the student list is traversed. For every student repo "
@@ -460,8 +478,8 @@ def _add_issue_parsers(base_parsers, issue_parsers):
         required=True,
     )
 
-    close_parser = issue_parsers.add_parser(
-        plug.CoreCommand.issues.close.name,
+    close_parser = add_parser(
+        plug.CoreCommand.issues.close,
         description=(
             "Close issues in student repos based on a regex. For each master "
             "repository specified, the student list is traversed. For every "
@@ -483,8 +501,8 @@ def _add_issue_parsers(base_parsers, issue_parsers):
         required=True,
     )
 
-    list_parser = issue_parsers.add_parser(
-        plug.CoreCommand.issues.list.name,
+    list_parser = add_parser(
+        plug.CoreCommand.issues.list,
         description="List issues in student repos.",
         help="List issues in student repos.",
         parents=[
@@ -593,7 +611,7 @@ def _add_extension_parsers(
     base_student_parser,
     master_org_parser,
     repo_name_parser,
-    categories,
+    parsers_mapping,
     parsed_config,
     show_all_opts,
 ):
@@ -616,47 +634,73 @@ def _add_extension_parsers(
         elif bp.REPO_NAMES in req_parsers:
             parents.append(repo_name_parser)
 
-        cmd_parser = (
-            cmd.parser(parsed_config, show_all_opts)
-            if callable(cmd.parser)
-            else cmd.parser
-        )
-        parents.append(cmd_parser)
-
-        category_parsers = categories.get(cmd.category) or subparsers
-        ext_parser = category_parsers.add_parser(
-            cmd.name,
-            help=cmd.help,
-            description=cmd.description,
-            parents=parents,
-            formatter_class=_OrderedFormatter,
-        )
+        def _add_ext_parser(
+            parents: List[argparse.ArgumentParser],
+        ) -> argparse.ArgumentParser:
+            """Add a new parser either to the extension command's category (if
+            specified), or to the top level subparsers (if category is not
+            specified).
+            """
+            return (
+                parsers_mapping.get(cmd.category) or subparsers
+            ).add_parser(
+                cmd.name,
+                help=cmd.help,
+                description=cmd.description,
+                parents=parents,
+                formatter_class=_OrderedFormatter,
+            )
+
+        if cmd.name in plug.CoreCommand:
+            ext_parser = parsers_mapping[cmd.name]
+            cmd.parser(
+                config=parsed_config,
+                show_all_opts=show_all_opts,
+                parser=ext_parser,
+            )
+        elif callable(cmd.parser):
+            action = cmd.category.get(cmd.name) if cmd.category else None
+            ext_parser = parsers_mapping.get(action) or _add_ext_parser(
+                parents
+            )
+            cmd.parser(
+                config=parsed_config,
+                show_all_opts=show_all_opts,
+                parser=ext_parser,
+            )
+        else:
+            parents.append(cmd.parser)
+            ext_parser = _add_ext_parser(parents)
+
         try:
             _add_traceback_arg(ext_parser)
         except argparse.ArgumentError:
             pass
 
-        ext_parser.add_argument(
-            "--repobee-action",
-            action="store_const",
-            help=argparse.SUPPRESS,
-            const=cmd.name,
-            default=cmd.name,
-            dest="action",
-        )
+        try:
+            # this will fail if we are adding arguments to an existing command
+            ext_parser.add_argument(
+                "--repobee-action",
+                action="store_const",
+                help=argparse.SUPPRESS,
+                const=cmd.name,
+                default=cmd.name,
+                dest="action",
+            )
+            # This is a little bit of a dirty trick. It allows us to easily
+            # find the associated extension command when parsing the arguments.
+            ext_parser.add_argument(
+                "--repobee-extension-command",
+                action="store_const",
+                help=argparse.SUPPRESS,
+                const=cmd,
+                default=cmd,
+                dest="_extension_command",
+            )
+        except argparse.ArgumentError:
+            pass
 
-        # This is a little bit of a dirty trick. It allows us to easily
-        # find the associated extension command when parsing the arguments.
-        ext_parser.add_argument(
-            "--repobee-extension-command",
-            action="store_const",
-            help=argparse.SUPPRESS,
-            const=cmd,
-            default=cmd,
-            dest="_extension_command",
-        )
-        if category_parsers == subparsers:
-            # in this case it's a category action, and we must add the category
+        try:
             ext_parser.add_argument(
                 "--repobee-category",
                 action="store_const",
@@ -665,6 +709,8 @@ def _add_extension_parsers(
                 default=cmd.name,
                 dest="category",
             )
+        except argparse.ArgumentError:
+            pass
 
     return ext_commands
 
diff --git a/src/repobee_plug/__init__.py b/src/repobee_plug/__init__.py
index 23c8ab3..b5276f8 100644
--- a/src/repobee_plug/__init__.py
+++ b/src/repobee_plug/__init__.py
@@ -19,6 +19,8 @@ from repobee_plug._containers import BaseParser
 from repobee_plug._containers import Deprecation
 from repobee_plug._containers import HookResult
 from repobee_plug._tasks import Task
+from repobee_plug._containers import Action
+from repobee_plug._containers import Category
 
 # Hook functions
 from repobee_plug._corehooks import PeerReviewHook as _peer_hook
@@ -80,6 +82,8 @@ __all__ = [
     "Review",
     "Task",
     "Deprecation",
+    "Action",
+    "Category",
     # API wrappers
     "Team",
     "TeamPermission",
diff --git a/src/repobee_plug/_containers.py b/src/repobee_plug/_containers.py
index b8f75a1..79b0fee 100644
--- a/src/repobee_plug/_containers.py
+++ b/src/repobee_plug/_containers.py
@@ -187,6 +187,9 @@ class Category(_ImmutableMixin, abc.ABC):
         object.__setattr__(self, "actions", tuple(actions))
         object.__setattr__(self, "_action_table", {a.name: a for a in actions})
 
+    def get(self, key: str) -> "Action":
+        return self._action_table.get(key)
+
     def __getitem__(self, key: str) -> "Action":
         return self._action_table[key]
 
@@ -232,6 +235,16 @@ class Action(_ImmutableMixin):
     def __str__(self):
         return f"{self.category.name} {self.name}"
 
+    def __eq__(self, other):
+        return (
+            isinstance(other, self.__class__)
+            and self.name == other.name
+            and self.category == other.category
+        )
+
+    def __hash__(self):
+        return hash(str(self))
+
     def asdict(self) -> Mapping[str, str]:
         """This is a convenience method for testing that returns a dictionary
         on the following form:
diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index 2db7c17..01981c8 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -1,5 +1,6 @@
 import argparse
 import daiquiri
+import functools
 
 from typing import List, Tuple, Callable, Mapping, Any
 
@@ -44,8 +45,17 @@ class _PluginMeta(type):
         Checking signatures is delegated to ``pluggy`` during registration of
         the hook.
         """
-        if cli.Command in bases:
-            ext_cmd_func = _generate_extension_command_func(attrdict)
+        if cli.Command in bases and cli.CommandExtension in bases:
+            raise _exceptions.PlugError(
+                "A plugin cannot be both a Command and a CommandExtension"
+            )
+
+        if cli.Command in bases or cli.CommandExtension in bases:
+            ext_cmd_func = (
+                _generate_command_func(attrdict)
+                if cli.Command in bases
+                else _generate_command_extension_func()
+            )
             attrdict[ext_cmd_func.__name__] = ext_cmd_func
 
         methods = cls._extract_public_methods(attrdict)
@@ -91,33 +101,48 @@ def _extract_cli_options(attrdict) -> List[Tuple[str, cli.Option]]:
     ]
 
 
-def _generate_extension_command_func(attrdict: Mapping[str, Any]) -> Callable:
-    """Generate an implementation of the ``create_extension_command`` hook
-    function based on the declarative-style extension command class.
+def _generate_command_extension_func() -> Callable:
+    """Generate an implementation of the ``create_extension_command`` that
+    returns an extension to an existing command.
     """
-    opts = _extract_cli_options(attrdict)
 
     def create_extension_command(self):
-        def create_parser(config, show_all_opts):
-            parser = _containers.ExtensionParser()
-            config_name = (
-                attrdict.get("__config_section__") or self.plugin_name
-            )
-            config_section = (
-                dict(config[config_name]) if config_name in config else {}
+        return _containers.ExtensionCommand(
+            parser=functools.partial(_attach_options, plugin=self),
+            name=self.__action__,
+            callback=lambda: None,
+            description=None,
+            help=None,
+        )
+
+    return create_extension_command
+
+
+def _attach_options(config, show_all_opts, parser, plugin: "Plugin"):
+    config_name = (
+        getattr(plugin, "__config_section__", None) or plugin.plugin_name
+    )
+    config_section = dict(config[config_name]) if config_name in config else {}
+
+    opts = _extract_cli_options(plugin.__class__.__dict__)
+    for (name, opt) in opts:
+        configured_value = config_section.get(name)
+        if configured_value and not opt.configurable:
+            raise _exceptions.PlugError(
+                f"Plugin '{plugin.plugin_name}' does not allow "
+                f"'{name}' to be configured"
             )
+        _add_option(name, opt, configured_value, show_all_opts, parser)
 
-            for (name, opt) in opts:
-                configured_value = config_section.get(name)
-                if configured_value and not opt.configurable:
-                    raise _exceptions.PlugError(
-                        f"Plugin '{self.plugin_name}' does not allow "
-                        f"'{name}' to be configured"
-                    )
-                _add_option(name, opt, configured_value, show_all_opts, parser)
+    return parser
 
-            return parser
 
+def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
+    """Generate an implementation of the ``create_extension_command`` hook
+    function based on the declarative-style extension command class.
+    """
+
+    def create_extension_command(self):
         category = attrdict.get("__category__")
         action_name = attrdict.get(
             "__action_name__"
@@ -128,7 +153,7 @@ def _generate_extension_command_func(attrdict: Mapping[str, Any]) -> Callable:
         base_parsers = attrdict.get("__base_parsers__") or None
 
         return _containers.ExtensionCommand(
-            parser=create_parser,
+            parser=functools.partial(_attach_options, plugin=self),
             name=action_name,
             help=help,
             description=description,
diff --git a/src/repobee_plug/cli.py b/src/repobee_plug/cli.py
index 1e3a027..2359850 100644
--- a/src/repobee_plug/cli.py
+++ b/src/repobee_plug/cli.py
@@ -1,7 +1,7 @@
 """Plugin functionality for creating extensions to the RepoBee CLI."""
 import collections
 
-__all__ = ["Option", "Command"]
+__all__ = ["Option", "Command", "CommandExtension"]
 
 
 Option = collections.namedtuple(
@@ -20,6 +20,12 @@ Option = collections.namedtuple(
 Option.__new__.__defaults__ = (None,) * len(Option._fields)
 
 
+class CommandExtension:
+    """Mixin class for use with the Plugin class. Marks the extending class as
+    a command extension, that adds options to an existing command.
+    """
+
+
 class Command:
     """Mixin class for use with the Plugin class. Explicitly marks a class as
     an extension command.


Test patch:
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 5709f31..cd674c2 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -1,3 +1,5 @@
+import argparse
+
 import pytest
 
 import repobee_plug as plug
@@ -5,6 +7,8 @@ import repobee_plug as plug
 from repobee_plug import _pluginmeta
 from repobee_plug import _exceptions
 
+import repobee
+
 
 class TestPluginInheritance:
     def test_raises_on_non_hook_public_method(self):
@@ -178,7 +182,8 @@ class TestDeclarativeExtensionCommand:
     def test_generated_parser(self, basic_greeting_command):
         """Test the parser that's generated automatically."""
         ext_cmd = basic_greeting_command("greeting").create_extension_command()
-        parser = ext_cmd.parser(config={}, show_all_opts=True)
+        parser = argparse.ArgumentParser()
+        ext_cmd.parser(config={}, show_all_opts=True, parser=parser)
         args = parser.parse_args("--name Eve".split())
 
         assert args.name == "Eve"
@@ -199,7 +204,8 @@ class TestDeclarativeExtensionCommand:
         configured_name = "Alice"
         config = {plugin_name: {"name": configured_name}}
         ext_cmd = Greeting("greeting").create_extension_command()
-        parser = ext_cmd.parser(config=config, show_all_opts=False)
+        parser = argparse.ArgumentParser()
+        ext_cmd.parser(config=config, show_all_opts=False, parser=parser)
         args = parser.parse_args([])
 
         assert args.name == configured_name
@@ -222,7 +228,11 @@ class TestDeclarativeExtensionCommand:
         ext_cmd = Greeting("greeting").create_extension_command()
 
         with pytest.raises(plug.PlugError) as exc_info:
-            ext_cmd.parser(config=config, show_all_opts=False)
+            ext_cmd.parser(
+                config=config,
+                show_all_opts=False,
+                parser=argparse.ArgumentParser(),
+            )
 
         assert (
             f"Plugin '{plugin_name}' does not allow 'name' to be configured"
@@ -246,7 +256,8 @@ class TestDeclarativeExtensionCommand:
                 pass
 
         ext_cmd = Greeting("g").create_extension_command()
-        parser = ext_cmd.parser(config={}, show_all_opts=False)
+        parser = argparse.ArgumentParser()
+        ext_cmd.parser(config={}, show_all_opts=False, parser=parser)
         name = "Alice"
 
         short_opt_args = parser.parse_args(f"-n {name}".split())
@@ -254,3 +265,50 @@ class TestDeclarativeExtensionCommand:
 
         assert short_opt_args.name == name
         assert long_opt_args.name == name
+
+
+class TestDeclarativeCommandExtension:
+    """Test creating command extensions to existing commands."""
+
+    def test_add_required_option_to_config_show(self, capsys):
+        """Tests adding a required option to ``config show``."""
+
+        class ConfigShowExt(plug.Plugin, plug.cli.CommandExtension):
+            __action__ = plug.CoreCommand.config.show
+
+            silly_new_option = plug.cli.Option(help="your name", required=True)
+
+        plugin_instance = ConfigShowExt("silly-option")
+
+        # TODO remove when using repobee.run instaed of repobee.main
+        plug.manager.register(plugin_instance)
+
+        with pytest.raises(SystemExit):
+            # TODO use repobee.run instead, when it is available
+            repobee.main("repobee config show".split())
+
+        # TODO remove when using repobee.run instaed of repobee.main
+        plug.manager.unregister(plugin_instance)
+
+        assert (
+            "the following arguments are required: --silly-new-option"
+            in capsys.readouterr().err
+        )
+
+    def test_raises_when_command_and_command_extension_are_subclassed_together(
+        self,
+    ):
+        """It should not be possible for a class to be both a command, and a
+        command extension.
+        """
+        with pytest.raises(plug.PlugError) as exc_info:
+
+            class Ext(
+                plug.Plugin, plug.cli.Command, plug.cli.CommandExtension
+            ):
+                pass
+
+        assert (
+            "A plugin cannot be both a Command and a CommandExtension"
+            in str(exc_info.value)
+        )