Repository: repobee/repobee

Issue:
Remove ExtensionCommand, ExtensionParser and create_extension_command
This is also going toward #501 

Plugin commands and command extensions can be implemented in a less roundabout way.

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

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

```diff
@@            Coverage Diff             @@
##           master     #528      +/-   ##
==========================================
- Coverage   92.24%   88.16%   -4.08%     
==========================================
  Files          47       47              
  Lines        2526     2458      -68     
  Branches      411      404       -7     
==========================================
- Hits         2330     2167     -163     
- Misses        152      241      +89     
- Partials       44       50       +6     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/528?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/repobee\_plug/\_exceptions.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fZXhjZXB0aW9ucy5weQ==) | `100.00% <ø> (ø)` | |
| [src/repobee\_plug/\_exthooks.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fZXh0aG9va3MucHk=) | `100.00% <ø> (ø)` | |
| [src/\_repobee/ext/query.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9xdWVyeS5weQ==) | `43.33% <36.36%> (-6.67%)` | :arrow_down: |
| [src/\_repobee/ext/dist/pluginmanager.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kaXN0L3BsdWdpbm1hbmFnZXIucHk=) | `57.14% <71.42%> (-3.73%)` | :arrow_down: |
| [src/\_repobee/cli/parsing.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9wYXJzaW5nLnB5) | `86.57% <83.33%> (-6.24%)` | :arrow_down: |
| [src/\_repobee/cli/mainparser.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9tYWlucGFyc2VyLnB5) | `92.92% <96.77%> (-4.05%)` | :arrow_down: |
| [src/\_repobee/cli/dispatch.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9kaXNwYXRjaC5weQ==) | `88.88% <100.00%> (ø)` | |
| [src/\_repobee/main.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL21haW4ucHk=) | `90.69% <100.00%> (+1.80%)` | :arrow_up: |
| [src/repobee\_plug/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/repobee\_plug/\_containers.py](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fY29udGFpbmVycy5weQ==) | `95.12% <100.00%> (+1.57%)` | :arrow_up: |
| ... and [15 more](https://codecov.io/gh/repobee/repobee/pull/528/diff?src=pr&el=tree-more) | |

------

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

slarse: I can't determine what's causing the integration test failures, not reproducible locally. Will merge and then try to fix it separately.

Patch:
diff --git a/src/_repobee/cli/dispatch.py b/src/_repobee/cli/dispatch.py
index 59374dc..b3fb3a9 100644
--- a/src/_repobee/cli/dispatch.py
+++ b/src/_repobee/cli/dispatch.py
@@ -21,10 +21,7 @@ LOGGER = daiquiri.getLogger(__file__)
 
 
 def dispatch_command(
-    args: argparse.Namespace,
-    api: plug.API,
-    config_file: pathlib.Path,
-    ext_commands: Optional[List[plug.ExtensionCommand]] = None,
+    args: argparse.Namespace, api: plug.API, config_file: pathlib.Path
 ) -> Mapping[str, List[plug.Result]]:
     """Handle parsed CLI arguments and dispatch commands to the appropriate
     functions. Expected exceptions are caught and turned into SystemExit
@@ -34,7 +31,6 @@ def dispatch_command(
         args: A namespace of parsed command line arguments.
         api: An initialized plug.API instance.
         config_file: Path to the config file.
-        ext_commands: A list of active extension commands.
     """
     hook_results = {}
     dispatch_table = {
@@ -47,8 +43,10 @@ def dispatch_command(
     is_ext_command = "_extension_command" in args
     if is_ext_command:
         ext_cmd = args._extension_command
-        res = ext_cmd.callback(api)
-        hook_results = {ext_cmd.name: [res]} if res else hook_results
+        res = ext_cmd.command(api)
+        hook_results = (
+            {str(ext_cmd.__settings__.action): [res]} if res else hook_results
+        )
     else:
         category = args.category
         hook_results = (
diff --git a/src/_repobee/cli/mainparser.py b/src/_repobee/cli/mainparser.py
index ea0502f..8fe52bc 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, Union, Mapping
+from typing import Union, Mapping
 
 import daiquiri
 
@@ -82,25 +82,19 @@ def create_parser_for_docs() -> argparse.ArgumentParser:
     daiquiri.setup(level=logging.FATAL)
     # load default plugins
     plugin.initialize_default_plugins()
-    ext_commands = plug.manager.hook.create_extension_command()
     return create_parser(
-        show_all_opts=True,
-        ext_commands=ext_commands,
-        config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
+        show_all_opts=True, config_file=_repobee.constants.DEFAULT_CONFIG_FILE
     )
 
 
 def create_parser(
-    show_all_opts: bool,
-    ext_commands: Optional[List[plug.ExtensionCommand]],
-    config_file: pathlib.Path,
+    show_all_opts: bool, config_file: pathlib.Path
 ) -> argparse.ArgumentParser:
     """Create the primary parser.
 
     Args:
         show_all_opts: If False, help sections for options with configured
             defaults are suppressed. Otherwise, all options are shown.
-        ext_commands: A list of extension commands.
         config_file: Path to the config file.
     Returns:
         The primary parser.
@@ -149,12 +143,12 @@ def create_parser(
         action="version",
         version="{}".format(_repobee.__version__),
     )
-    _add_subparsers(parser, show_all_opts, ext_commands, config_file)
+    _add_subparsers(parser, show_all_opts, config_file)
 
     return parser
 
 
-def _add_subparsers(parser, show_all_opts, ext_commands, config_file):
+def _add_subparsers(parser, show_all_opts, config_file):
     """Add all of the subparsers to the parser. Note that the parsers prefixed
     with `base_` do not have any parent parsers, so any parser inheriting from
     them must also inherit from the required `base_parser` (unless it is a
@@ -229,9 +223,8 @@ def _add_subparsers(parser, show_all_opts, ext_commands, config_file):
         base_parser, master_org_parser, _add_action_parser(config_parsers)
     )
 
-    return _add_extension_parsers(
+    _add_extension_parsers(
         subparsers,
-        ext_commands,
         base_parser,
         base_student_parser,
         master_org_parser,
@@ -602,7 +595,6 @@ class _OrderedFormatter(argparse.HelpFormatter):
 
 def _add_extension_parsers(
     subparsers,
-    ext_commands,
     base_parser,
     base_student_parser,
     master_org_parser,
@@ -612,13 +604,52 @@ def _add_extension_parsers(
     show_all_opts,
 ):
     """Add extension parsers defined by plugins."""
-    if not ext_commands:
-        return []
-    for cmd in ext_commands:
+    command_extension_plugins = [
+        p
+        for p in plug.manager.get_plugins()
+        if isinstance(p, plug.cli.CommandExtension)
+    ]
+    for cmd in command_extension_plugins:
+        for action in cmd.__settings__.actions:
+            parser = parsers_mapping[action]
+            cmd.attach_options(
+                config=parsed_config,
+                show_all_opts=show_all_opts,
+                parser=parser,
+            )
+
+    command_plugins = [
+        p
+        for p in plug.manager.get_plugins()
+        if isinstance(p, plug.cli.Command)
+    ]
+    for cmd in command_plugins:
+        is_category_action = False
+        settings = cmd.__settings__
+        category = (
+            settings.action.category
+            if isinstance(settings.action, categorization.Action)
+            else settings.category
+        )
+        action = settings.action or cmd.__class__.__name__.lower().replace(
+            "_", "-"
+        )
+        if isinstance(action, str):
+            if not category:
+                is_category_action = True
+                category = plug.cli.category(
+                    name=action, action_names=[action]
+                )
+            action = (
+                category[action]
+                if category and action in category
+                else categorization.Action(name=action, category=category)
+            )
+
         parents = []
         bp = plug.BaseParser
-        req_parsers = cmd.requires_base_parsers or []
-        if cmd.requires_api or bp.BASE in req_parsers:
+        req_parsers = settings.base_parsers or []
+        if settings.requires_api or bp.BASE in req_parsers:
             parents.append(base_parser)
         if bp.STUDENTS in req_parsers:
             parents.append(base_student_parser)
@@ -630,29 +661,11 @@ def _add_extension_parsers(
         elif bp.REPO_NAMES in req_parsers:
             parents.append(repo_name_parser)
 
-        def _add_ext_parser(
-            name=None, parents: List[argparse.ArgumentParser] = None
-        ) -> 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(
-                name or cmd.name,
-                help=cmd.help,
-                description=cmd.description,
-                parents=parents,
-                formatter_class=_OrderedFormatter,
-            )
-
-        category = (
-            cmd.name.category
-            if isinstance(cmd.name, categorization.Action)
-            else cmd.category
-        )
-        if category and category not in parsers_mapping:
+        if (
+            category
+            and category not in parsers_mapping
+            and not is_category_action
+        ):
             # new category
             category_cmd = subparsers.add_parser(
                 category.name,
@@ -663,34 +676,15 @@ def _add_extension_parsers(
             category_parsers.required = True
             parsers_mapping[category] = category_parsers
 
-        if cmd.name in parsers_mapping:
-            ext_parser = parsers_mapping[cmd.name]
-            cmd.parser(
-                config=parsed_config,
-                show_all_opts=show_all_opts,
-                parser=ext_parser,
-            )
-        elif isinstance(cmd.name, categorization.Action):
-            action = cmd.name
-            ext_parser = _add_ext_parser(parents=parents, name=action.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=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=parents)
+        assert action not in parsers_mapping
+
+        ext_parser = (parsers_mapping.get(category) or subparsers).add_parser(
+            action.name,
+            help=settings.help,
+            description=settings.description,
+            parents=parents,
+            formatter_class=_OrderedFormatter,
+        )
 
         try:
             _add_traceback_arg(ext_parser)
@@ -703,35 +697,43 @@ def _add_extension_parsers(
                 "--repobee-action",
                 action="store_const",
                 help=argparse.SUPPRESS,
-                const=cmd.name,
-                default=cmd.name,
+                const=action.name,
+                default=action.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.
-            if cmd.callback:
-                ext_parser.add_argument(
-                    "--repobee-extension-command",
-                    action="store_const",
-                    help=argparse.SUPPRESS,
-                    const=cmd,
-                    default=cmd,
-                    dest="_extension_command",
-                )
+            ext_parser.add_argument(
+                "--repobee-extension-command",
+                action="store_const",
+                help=argparse.SUPPRESS,
+                const=cmd,
+                default=cmd,
+                dest="_extension_command",
+            )
         except argparse.ArgumentError:
             pass
 
-        if isinstance(cmd.name, str) and not cmd.category:
+        if is_category_action:
+            # category is not specified, so it's a category-action
             ext_parser.add_argument(
                 "--repobee-category",
                 action="store_const",
                 help=argparse.SUPPRESS,
-                const=cmd.name,
-                default=cmd.name,
+                const=category,
+                default=category,
                 dest="category",
             )
 
-    return ext_commands
+        cmd.attach_options(
+            config=parsed_config,
+            show_all_opts=show_all_opts,
+            parser=ext_parser,
+        )
+
+        settings_dict = settings._asdict()
+        settings_dict.update(dict(action=action, category=category))
+        cmd.__settings__ = settings.__class__(**settings_dict)
 
 
 def _create_base_parsers(show_all_opts, config_file):
diff --git a/src/_repobee/cli/parsing.py b/src/_repobee/cli/parsing.py
index c8bffa5..ee7cddf 100644
--- a/src/_repobee/cli/parsing.py
+++ b/src/_repobee/cli/parsing.py
@@ -41,7 +41,6 @@ class _ArgsProcessing(enum.Enum):
 def handle_args(
     sys_args: Iterable[str],
     show_all_opts: bool = False,
-    ext_commands: Optional[List[plug.ExtensionCommand]] = None,
     config_file: pathlib.Path = constants.DEFAULT_CONFIG_FILE,
 ) -> Tuple[argparse.Namespace, Optional[plug.API]]:
     """Parse and process command line arguments and instantiate the platform
@@ -54,19 +53,16 @@ def handle_args(
         show_all_opts: If False, help sections for options that have
             configured defaults are suppressed. Otherwise, all options are
             shown.
-        ext_commands: An optional list of extension commands.
 
     Returns:
         A tuple of a namespace with parsed and processed arguments, and an
         instance of the platform API if it is required for the command.
     """
-    args, processing = _parse_args(
-        sys_args, config_file, show_all_opts, ext_commands
-    )
+    args, processing = _parse_args(sys_args, config_file, show_all_opts)
     plug.manager.hook.handle_parsed_args(args=args)
 
     if processing == _ArgsProcessing.CORE:
-        processed_args, api = _process_args(args, ext_commands)
+        processed_args, api = _process_args(args)
         return processed_args, api
     elif processing == _ArgsProcessing.EXT:
         processed_args, api = _process_ext_args(args)
@@ -78,7 +74,6 @@ def _parse_args(
     sys_args: Iterable[str],
     config_file: pathlib.Path,
     show_all_opts: bool = False,
-    ext_commands: Optional[List[plug.ExtensionCommand]] = None,
 ) -> Tuple[argparse.Namespace, _ArgsProcessing]:
     """Parse the command line arguments with some light processing. Any
     processing that requires external resources (such as a network connection)
@@ -89,15 +84,12 @@ def _parse_args(
         config_file: Path to the config file.
         show_all_opts: If False, CLI arguments that are configure in the
             configuration file are not shown in help menus.
-        ext_commands: A list of extension commands.
 
     Returns:
         A namespace of parsed arpuments and a boolean that specifies whether or
         not further processing is required.
     """
-    parser = cli.mainparser.create_parser(
-        show_all_opts, ext_commands, config_file
-    )
+    parser = cli.mainparser.create_parser(show_all_opts, config_file)
     args = parser.parse_args(_handle_deprecation(sys_args))
 
     if "_extension_command" in args:
@@ -133,15 +125,6 @@ def _parse_args(
     return argparse.Namespace(**args_dict), requires_processing
 
 
-def _resolve_extension_command(
-    subparser, ext_commands
-) -> Optional[plug.ExtensionCommand]:
-    ext_command_names = [cmd.name for cmd in ext_commands or []]
-    if subparser in ext_command_names:
-        return ext_commands[ext_command_names.index(subparser)]
-    return None
-
-
 def _resolve_requires_processing(args: argparse.Namespace) -> _ArgsProcessing:
     """Figure out if further processing of the parsed args is required.
     This is primarily decided on whether or not the platform API is required,
@@ -157,7 +140,6 @@ def _resolve_requires_processing(args: argparse.Namespace) -> _ArgsProcessing:
 
 def _process_args(
     args: argparse.Namespace,
-    ext_commands: Optional[List[plug.ExtensionCommand]] = None,
 ) -> Tuple[argparse.Namespace, plug.API]:
     """Process parsed command line arguments.
 
@@ -314,9 +296,10 @@ def _process_ext_args(
 ) -> Tuple[argparse.Namespace, Optional[plug.API]]:
     ext_cmd = args._extension_command
     assert ext_cmd
+    settings = ext_cmd.__settings__
 
     api = None
-    if ext_cmd.requires_api:
+    if settings.requires_api:
         _validate_tls_url(args.base_url)
         api = _connect_to_api(
             args.base_url,
@@ -326,7 +309,7 @@ def _process_ext_args(
         )
 
     args_dict = vars(args)
-    req_parsers = ext_cmd.requires_base_parsers or []
+    req_parsers = settings.base_parsers or []
     bp = plug.BaseParser
     if bp.STUDENTS in req_parsers:
         args_dict["students"] = _extract_groups(args)
diff --git a/src/_repobee/ext/dist/pluginmanager.py b/src/_repobee/ext/dist/pluginmanager.py
index c2a79fa..c443a4d 100644
--- a/src/_repobee/ext/dist/pluginmanager.py
+++ b/src/_repobee/ext/dist/pluginmanager.py
@@ -5,7 +5,6 @@ tooling.
 
     This plugin should only be used when using an installed version of RepoBee.
 """
-import argparse
 import subprocess
 
 import daiquiri
@@ -17,23 +16,39 @@ import _repobee.distinfo
 LOGGER = daiquiri.getLogger(__file__)
 PLUGIN = "pluginmanager"
 
+plugin_category = plug.cli.category(name="plugin", action_names=["install"])
+
 
 class InstallPluginCommand(plug.Plugin):
     """Extension command for installing a plugin."""
 
-    def _install_plugin(
-        self, args: argparse.ArgumentParser, api: None
-    ) -> None:
+    __settings__ = plug.cli.command_settings(
+        action=plugin_category.install,
+        help="Install a plugin.",
+        description="Install a plugin.",
+    )
+
+    version = plug.cli.option(
+        help="The version to install. Should be on the form "
+        "'MAJOR.MINOR.PATCH. Example: '1.2.0'''",
+        converter=str,
+        required=True,
+    )
+    name = plug.cli.option(
+        "--name", help="Name of the plugin.", converter=str, required=True
+    )
+
+    def command(self, api: None) -> None:
         """Install a plugin."""
         version = (
-            args.version
-            if args.version.startswith("v")
-            else f"v{args.version}"
+            self.version
+            if self.version.startswith("v")
+            else f"v{self.version}"
         )
         plugin_name = (
-            args.name
-            if args.name.startswith("repobee-")
-            else f"repobee-{args.name}"
+            self.name
+            if self.name.startswith("repobee-")
+            else f"repobee-{self.name}"
         )
         plugin_url = (
             f"git+https://github.com/repobee/" f"{plugin_name}.git@{version}"
@@ -49,29 +64,9 @@ class InstallPluginCommand(plug.Plugin):
         proc = subprocess.run(cmd)
 
         if proc.returncode != 0:
-            LOGGER.exception(f"Failed to install {args.name} {args.version}")
+            LOGGER.exception(f"Failed to install {self.name} {self.version}")
             raise plug.PlugError(
-                f"could not install {args.name} {args.version}"
+                f"could not install {self.name} {self.version}"
             )
 
-        LOGGER.info(f"Installed {args.name} {args.version}")
-
-    def create_extension_command(self):
-        parser = plug.ExtensionParser()
-        parser.add_argument(
-            "--name", help="Name of the plugin.", type=str, required=True
-        )
-        parser.add_argument(
-            "--version",
-            help="The version to install. Should be on the form "
-            "'MAJOR.MINOR.PATCH'. Example: '1.2.0'",
-            type=str,
-            required=True,
-        )
-        return plug.ExtensionCommand(
-            parser=parser,
-            name="install-plugin",
-            help="Install a plugin.",
-            description="Install a plugin.",
-            callback=self._install_plugin,
-        )
+        LOGGER.info(f"Installed {self.name} {self.version}")
diff --git a/src/_repobee/ext/query.py b/src/_repobee/ext/query.py
index 2b1353a..67358bd 100644
--- a/src/_repobee/ext/query.py
+++ b/src/_repobee/ext/query.py
@@ -6,7 +6,6 @@ a hook results JSON file.
 
 .. moduleauthor:: Simon Larsén
 """
-import argparse
 import pathlib
 import sys
 import collections
@@ -19,46 +18,39 @@ from _repobee import formatters
 LOGGER = daiquiri.getLogger(__file__)
 
 
-def callback(args: argparse.Namespace, api: plug.API) -> None:
-    hook_results_file = pathlib.Path(args.hook_results_file).resolve()
-    if not hook_results_file.exists():
-        raise plug.PlugError("no such file: {}".format(str(hook_results_file)))
-
-    contents = hook_results_file.read_text(encoding=sys.getdefaultencoding())
-    hook_results_mapping = plug.json_to_result_mapping(contents)
-    selected_hook_results = _filter_hook_results(
-        hook_results_mapping, args.students, args.master_repo_names
+class Query(plug.Plugin, plug.cli.Command):
+    __settings__ = plug.cli.command_settings(
+        help="Query a hook results JSON file for information.",
+        description="Query a hook results JSON file for information.",
+        base_parsers=[plug.BaseParser.STUDENTS, plug.BaseParser.REPO_NAMES],
     )
-    LOGGER.info(formatters.format_hook_results_output(selected_hook_results))
-
 
-@plug.repobee_hook
-def create_extension_command():
-    LOGGER.warning(
-        "query is an experimental plugin and may be altered or removed "
-        "without notice."
-    )
-    parser = plug.ExtensionParser()
-    parser.add_argument(
-        "--hf",
-        "--hook-results-file",
+    hook_results_file = plug.cli.option(
+        short_name="--hf",
         help="Path to an existing hook results file.",
-        type=str,
         required=True,
-        dest="hook_results_file",
-    )
-    return plug.ExtensionCommand(
-        parser=parser,
-        name="query",
-        help="Query a hook results JSON file for information.",
-        description="Query a hook results JSON file for information.",
-        callback=callback,
-        requires_base_parsers=[
-            plug.BaseParser.STUDENTS,
-            plug.BaseParser.REPO_NAMES,
-        ],
     )
 
+    def command(self, api: plug.API) -> None:
+        hook_results_file = pathlib.Path(self.hook_results_file).resolve()
+        if not hook_results_file.exists():
+            raise plug.PlugError(
+                "no such file: {}".format(str(hook_results_file))
+            )
+
+        contents = hook_results_file.read_text(
+            encoding=sys.getdefaultencoding()
+        )
+        hook_results_mapping = plug.json_to_result_mapping(contents)
+        selected_hook_results = _filter_hook_results(
+            hook_results_mapping,
+            self.args.students,
+            self.args.master_repo_names,
+        )
+        LOGGER.info(
+            formatters.format_hook_results_output(selected_hook_results)
+        )
+
 
 def _filter_hook_results(hook_results_mapping, teams, master_repo_names):
     """Return an OrderedDict of hook result mappings for which the repo name is
diff --git a/src/_repobee/main.py b/src/_repobee/main.py
index 1faa256..1cc3c34 100644
--- a/src/_repobee/main.py
+++ b/src/_repobee/main.py
@@ -115,11 +115,9 @@ def run(
             _repobee.cli.parsing.setup_logging()
             plugin.initialize_default_plugins()
             plugin.register_plugins(wrapped_plugins)
-            parsed_args, api, ext_commands = _parse_args(
-                cmd, config_file, show_all_opts
-            )
+            parsed_args, api = _parse_args(cmd, config_file, show_all_opts)
             return _repobee.cli.dispatch.dispatch_command(
-                parsed_args, api, config_file, ext_commands
+                parsed_args, api, config_file
             )
         finally:
             plugin.unregister_all_plugins()
@@ -161,14 +159,12 @@ def main(sys_args: List[str], unload_plugins: bool = True):
             ) or []
             plugin.initialize_plugins(plugin_names, allow_filepath=True)
 
-        parsed_args, api, ext_commands = _parse_args(
+        parsed_args, api = _parse_args(
             app_args, config_file, parsed_preparser_args.show_all_opts
         )
         traceback = parsed_args.traceback
         pre_init = False
-        _repobee.cli.dispatch.dispatch_command(
-            parsed_args, api, config_file, ext_commands
-        )
+        _repobee.cli.dispatch.dispatch_command(parsed_args, api, config_file)
     except exception.PluginLoadError as exc:
         LOGGER.error("{.__class__.__name__}: {}".format(exc, str(exc)))
         LOGGER.error(
@@ -197,15 +193,11 @@ def main(sys_args: List[str], unload_plugins: bool = True):
 
 def _parse_args(args, config_file, show_all_opts):
     config.execute_config_hooks(config_file)
-    ext_commands = plug.manager.hook.create_extension_command()
     parsed_args, api = _repobee.cli.parsing.handle_args(
-        args,
-        show_all_opts=show_all_opts,
-        ext_commands=ext_commands,
-        config_file=config_file,
+        args, show_all_opts=show_all_opts, config_file=config_file
     )
     plug.manager.hook.handle_processed_args(args=parsed_args)
-    return parsed_args, api, ext_commands
+    return parsed_args, api
 
 
 if __name__ == "__main__":
diff --git a/src/repobee_plug/__init__.py b/src/repobee_plug/__init__.py
index 70a57f2..f30f81a 100644
--- a/src/repobee_plug/__init__.py
+++ b/src/repobee_plug/__init__.py
@@ -11,8 +11,6 @@ from repobee_plug._pluginmeta import cli
 from repobee_plug._containers import Review
 from repobee_plug._containers import Result
 from repobee_plug._containers import Status
-from repobee_plug._containers import ExtensionParser
-from repobee_plug._containers import ExtensionCommand
 from repobee_plug._containers import ReviewAllocation
 from repobee_plug._containers import BaseParser
 from repobee_plug._containers import Deprecation
@@ -23,7 +21,6 @@ from repobee_plug._corehooks import PeerReviewHook as _peer_hook
 from repobee_plug._corehooks import APIHook as _api_hook
 from repobee_plug._exthooks import CloneHook as _clone_hook
 from repobee_plug._exthooks import SetupHook as _setup_hook
-from repobee_plug._exthooks import ExtensionCommandHook as _ext_command_hook
 
 # Helpers
 from repobee_plug._deprecation import deprecate, deprecated_hooks
@@ -49,18 +46,13 @@ from repobee_plug._apimeta import (
 )
 
 # Exceptions
-from repobee_plug._exceptions import (
-    ExtensionCommandError,
-    HookNameError,
-    PlugError,
-)
+from repobee_plug._exceptions import HookNameError, PlugError
 
 manager = pluggy.PluginManager(__package__)
 manager.add_hookspecs(_clone_hook)
 manager.add_hookspecs(_setup_hook)
 manager.add_hookspecs(_peer_hook)
 manager.add_hookspecs(_api_hook)
-manager.add_hookspecs(_ext_command_hook)
 
 __all__ = [
     # Plugin stuff
@@ -71,8 +63,6 @@ __all__ = [
     "Result",
     "HookResult",
     "Status",
-    "ExtensionParser",
-    "ExtensionCommand",
     "ReviewAllocation",
     "Review",
     "Deprecation",
@@ -86,7 +76,6 @@ __all__ = [
     "API",
     "APISpec",
     # Exceptions
-    "ExtensionCommandError",
     "HookNameError",
     "PlugError",
     # Helpers
diff --git a/src/repobee_plug/_containers.py b/src/repobee_plug/_containers.py
index 57b023f..6bf6fc0 100644
--- a/src/repobee_plug/_containers.py
+++ b/src/repobee_plug/_containers.py
@@ -7,16 +7,12 @@
 """
 import collections
 import enum
-import argparse
 import pluggy
 
-from typing import Mapping, Any, Optional, Callable, Iterable, List
+from typing import Mapping, Any, Optional
 
 import daiquiri
 
-from repobee_plug import _exceptions
-from repobee_plug import _apimeta
-
 LOGGER = daiquiri.getLogger(__file__)
 
 hookspec = pluggy.HookspecMarker(__package__)
@@ -89,13 +85,6 @@ def HookResult(hook, status, msg, data=None) -> Result:
     return Result(name=hook, status=status, msg=msg, data=data)
 
 
-class ExtensionParser(argparse.ArgumentParser):
-    """An ArgumentParser specialized for RepoBee extension commands."""
-
-    def __init__(self):
-        super().__init__(add_help=False)
-
-
 class BaseParser(enum.Enum):
     """Enumeration of base parsers that an extension command can request to
     have added to it.
@@ -131,118 +120,6 @@ class ImmutableMixin:
         self.__setattr__(name, value)
 
 
-class ExtensionCommand(
-    collections.namedtuple(
-        "ExtensionCommand",
-        (
-            "parser",
-            "name",
-            "help",
-            "description",
-            "callback",
-            "requires_api",
-            "requires_base_parsers",
-            "category",
-        ),
-    )
-):
-    """Class defining an extension command for the RepoBee CLI."""
-
-    def __new__(
-        cls,
-        parser: ExtensionParser,
-        name: str,
-        help: str,
-        description: str,
-        callback: Callable[
-            [argparse.Namespace, Optional[_apimeta.API]],
-            Optional[Mapping[str, Result]],
-        ],
-        requires_api: bool = False,
-        requires_base_parsers: Optional[List[BaseParser]] = None,
-        category: Optional = None,
-    ):
-        if not (isinstance(parser, ExtensionParser) or callable(parser)):
-            raise _exceptions.ExtensionCommandError(
-                "parser must be a {.__name__} or callable".format(
-                    ExtensionParser
-                )
-            )
-        if (
-            BaseParser.REPO_DISCOVERY in (requires_base_parsers or [])
-            and not requires_api
-        ):
-            raise _exceptions.ExtensionCommandError(
-                "must set requires_api=True to use REPO_DISCOVERY base parser"
-            )
-        return super().__new__(
-            cls,
-            parser,
-            name,
-            help,
-            description,
-            callback,
-            requires_api,
-            requires_base_parsers,
-            category,
-        )
-
-    # the init method is just for documentation purposes
-    def __init__(
-        self,
-        parser: ExtensionParser,
-        name: str,
-        help: str,
-        description: str,
-        callback: Callable[
-            [argparse.Namespace, Optional[_apimeta.API]], Optional[Result]
-        ],
-        requires_api: bool = False,
-        requires_base_parsers: Optional[Iterable[BaseParser]] = None,
-        category: Optional = None,
-    ):
-        """
-        Args:
-            parser: The parser to use for the CLI.
-            name: Name of the command.
-            help: Text that will be displayed when running ``repobee -h``
-            description: Text that will be displayed when calling the ``-h``
-                option for this specific command. Should be elaborate in
-                describing the usage of the command.
-            callback: A callback function that is called if this command is
-                used on the CLI. It is passed the parsed namespace and the
-                platform API. It may optionally return a plugin result that's
-                reported by RepoBee's CLI.
-            requires_api: If True, the base arguments required for the platform
-                API are added as options to the extension command, and the
-                platform API is then passed to the callback function. It is
-                then important not to have clashing option names. If False, the
-                base arguments are not added to the CLI, and None is passed in
-                place of the API. If you include ``REPO_DISCOVERY`` in
-                ``requires_base_parsers``, then you *must* set this to True.
-            requires_base_parsers: A list of
-                :py:class:`repobee_plug.BaseParser` that decide which base
-                parsers are added to this command. For example, setting
-                ``requires_base_parsers = [BaseParser.STUDENTS]`` adds the
-                ``--students`` and ``--students-file`` options to this
-                extension command's parser.
-            category: The category to place this parser in. If ``None``, this
-                becomes a top-level command.
-        """
-        super().__init__()
-
-    def __eq__(self, other):
-        """Two ExtensionCommands are equal if they compare equal in all
-        respects except for the parser, as argpars.ArgumentParser instances do
-        not implement __eq__.
-        """
-        if not isinstance(other, self.__class__):
-            return False
-        _, *rest = self
-        _, *other_rest = other
-        return rest == other_rest
-
-
 ReviewAllocation = collections.namedtuple(
     "ReviewAllocation", "review_team reviewed_team"
 )
diff --git a/src/repobee_plug/_exceptions.py b/src/repobee_plug/_exceptions.py
index 84da07c..6d65571 100644
--- a/src/repobee_plug/_exceptions.py
+++ b/src/repobee_plug/_exceptions.py
@@ -49,11 +49,5 @@ class HookNameError(PlugError):
     """
 
 
-class ExtensionCommandError(PlugError):
-    """Raise when an :py:class:~repobee_plug.containers.ExtensionCommand: is
-    incorrectly defined.
-    """
-
-
 class APIImplementationError(PlugError):
     """Raise when an API is defined incorrectly."""
diff --git a/src/repobee_plug/_exthooks.py b/src/repobee_plug/_exthooks.py
index 003d31e..f8adf4e 100644
--- a/src/repobee_plug/_exthooks.py
+++ b/src/repobee_plug/_exthooks.py
@@ -18,7 +18,6 @@ from typing import Union, Optional
 from repobee_plug._apimeta import API
 from repobee_plug._containers import hookspec
 from repobee_plug._containers import Result
-from repobee_plug._containers import ExtensionCommand
 from repobee_plug._deprecation import deprecate
 
 
@@ -114,66 +113,3 @@ class SetupHook:
             May also return None, in which case no reporting will be performed
             for the hook.
         """
-
-
-class ExtensionCommandHook:
-    """Hooks related to extension commands."""
-
-    @hookspec
-    def create_extension_command(self) -> ExtensionCommand:
-        """Create an extension command to add to the RepoBee CLI. The command will
-        be added as one of the top-level subcommands of RepoBee. This hook is
-        called precisely once, and should return an
-        :py:class:`~repobee_plug.ExtensionCommand`.
-
-        .. code-block:: python
-
-            def command(args: argparse.Namespace, api: apimeta.API)
-
-        The ``command`` function will be called if the extension command is
-        used on the command line.
-
-        Note that the
-        :py:class:`~repobee_plug.containers.RepoBeeExtensionParser` class is
-        just a thin wrapper around :py:class:`argparse.ArgumentParser`, and can
-        be used in an identical manner. The following is an example definition
-        of this hook that adds a subcommand called ``example-command``, that
-        can be called with ``repobee example-command``.
-
-        .. code-block:: python
-
-            import repobee_plug as plug
-
-            def callback(args: argparse.Namespace, api: plug.API) -> None:
-                LOGGER.info("callback called with: {}, {}".format(args, api))
-
-            @plug.repobee_hook
-            def create_extension_command():
-                parser = plug.RepoBeeExtensionParser()
-                parser.add_argument("-b", "--bb", help="A useless argument")
-                return plug.ExtensionCommand(
-                    parser=parser,
-                    name="example-command",
-                    help="An example command",
-                    description="Description of an example command",
-                    callback=callback,
-                )
-
-        .. important:
-
-            The ``-tb|--traceback`` argument is always added to the parser.
-            Make sure not to add any conflicting arguments.
-
-        .. important::
-
-            If you need to use the api, you set ``requires_api=True`` in the
-            ``ExtensionCommand``. This will automatically add the options that
-            the API requires to the CLI options of the subcommand, and
-            initialize the api and pass it in.
-
-        See the documentation for :py:class:`~repobee_plug.ExtensionCommand`
-        for more details on it.
-
-        Returns:
-            A :py:class:`~repobee_plug.containers.ExtensionCommand`.
-        """
diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index 8316a60..94f4cd5 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -1,9 +1,8 @@
 import argparse
 import itertools
 import daiquiri
-import functools
 
-from typing import List, Tuple, Callable, Mapping, Any, Union
+from typing import List, Tuple, Union
 
 from repobee_plug import _exceptions
 from repobee_plug import _corehooks
@@ -22,7 +21,6 @@ _HOOK_METHODS = {
         *_exthooks.SetupHook.__dict__.items(),
         *_corehooks.PeerReviewHook.__dict__.items(),
         *_corehooks.APIHook.__dict__.items(),
-        *_exthooks.ExtensionCommandHook.__dict__.items(),
     ]
     if callable(value) and not key.startswith("_")
 }
@@ -30,11 +28,8 @@ _HOOK_METHODS = {
 
 class _PluginMeta(type):
     """Metaclass used for converting methods with appropriate names into
-    hook methods. It also enables the declarative style of extension commands
-    by automatically implementing the ``create_extension_command`` hook
-    for any inheriting class that declares CLI-related members.
-
-    Also ensures that all public methods have the name of a hook method.
+    hook methods. It ensures that all public methods have the name of a hook
+    method.
 
     Checking signatures is handled by pluggy on registration.
     """
@@ -54,19 +49,19 @@ class _PluginMeta(type):
             )
 
         if cli.Command in bases:
-            ext_cmd_func = _generate_command_func(attrdict)
-            attrdict[ext_cmd_func.__name__] = ext_cmd_func
+            if "__settings__" not in attrdict:
+                attrdict["__settings__"] = cli.command_settings()
             handle_processed_args = _generate_handle_processed_args_func()
             attrdict[handle_processed_args.__name__] = handle_processed_args
+            attrdict["attach_options"] = _attach_options
         elif cli.CommandExtension in bases:
             if "__settings__" not in attrdict:
                 raise _exceptions.PlugError(
                     "CommandExtension must have a '__settings__' attribute"
                 )
-            ext_cmd_func = _generate_command_extension_func()
-            attrdict[ext_cmd_func.__name__] = ext_cmd_func
             handle_processed_args = _generate_handle_processed_args_func()
             attrdict[handle_processed_args.__name__] = handle_processed_args
+            attrdict["attach_options"] = _attach_options
 
         methods = cls._extract_public_methods(attrdict)
         cls._check_names(methods)
@@ -94,7 +89,9 @@ class _PluginMeta(type):
         return {
             key: value
             for key, value in attrdict.items()
-            if callable(value) and not key.startswith("_") and key != "command"
+            if callable(value)
+            and not key.startswith("_")
+            and key not in ["command", "attach_options"]
         }
 
 
@@ -112,42 +109,18 @@ def _extract_cli_options(
     ]
 
 
-def _generate_command_extension_func() -> Callable:
-    """Generate an implementation of the ``create_extension_command`` that
-    returns an extension to an existing command.
-    """
-
-    def create_extension_command(self):
-        settings = self.__settings__
-        if settings.config_section_name:
-            self.__config_section__ = settings.config_section_name
-
-        return _containers.ExtensionCommand(
-            parser=functools.partial(_attach_options, plugin=self),
-            # FIXME support more than one action
-            name=settings.actions[0],
-            callback=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
-    )
+def _attach_options(self, config, show_all_opts, parser):
+    config_name = self.__settings__.config_section_name or self.plugin_name
     config_section = dict(config[config_name]) if config_name in config else {}
 
-    opts = _extract_cli_options(plugin.__class__.__dict__)
+    opts = _extract_cli_options(self.__class__.__dict__)
     for (name, opt) in opts:
         configured_value = config_section.get(name)
         if configured_value and not (
             hasattr(opt, "configurable") and opt.configurable
         ):
             raise _exceptions.PlugError(
-                f"Plugin '{plugin.plugin_name}' does not allow "
+                f"Plugin '{self.plugin_name}' does not allow "
                 f"'{name}' to be configured"
             )
         _add_option(name, opt, configured_value, show_all_opts, parser)
@@ -155,31 +128,6 @@ def _attach_options(config, show_all_opts, parser, plugin: "Plugin"):
     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):
-        settings = attrdict.get("__settings__") or cli.command_settings()
-        if settings.config_section_name:
-            self.__config_section__ = settings.config_section_name
-
-        return _containers.ExtensionCommand(
-            parser=functools.partial(_attach_options, plugin=self),
-            name=settings.action
-            or self.__class__.__name__.lower().replace("_", "-"),
-            help=settings.help,
-            description=settings.description,
-            callback=self.command,
-            category=settings.category,
-            requires_api=settings.requires_api,
-            requires_base_parsers=settings.base_parsers,
-        )
-
-    return create_extension_command
-
-
 def _generate_handle_processed_args_func():
     def handle_processed_args(self, args):
         self.args = args
diff --git a/src/repobee_plug/cli/commandmarkers.py b/src/repobee_plug/cli/commandmarkers.py
index ac711d0..6fe41b9 100644
--- a/src/repobee_plug/cli/commandmarkers.py
+++ b/src/repobee_plug/cli/commandmarkers.py
@@ -1,11 +1,15 @@
 """Mixin classes for marking plugins as CLI commands/extensions."""
 
+import argparse
+
 
 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.
     """
 
+    args: argparse.Namespace
+
 
 class Command:
     """Mixin class for use with the Plugin class. Explicitly marks a class as
@@ -60,3 +64,5 @@ class Command:
         $ repobee -p command.py greeting -n Alice
         Hello, my name is Alice and I am 30
     """
+
+    args: argparse.Namespace


Test patch:
diff --git a/tests/integration_tests/docker-compose.yml b/tests/integration_tests/docker-compose.yml
index afbd8bf..f5ac3a5 100644
--- a/tests/integration_tests/docker-compose.yml
+++ b/tests/integration_tests/docker-compose.yml
@@ -1,7 +1,7 @@
 version: '3'
 services:
   gitlab:
-    image: gitlab/gitlab-ce:13.0.6-ce.0
+    image: gitlab/gitlab-ce:13.2.1-ce.0
     container_name: gitlab
     restart: always
     environment:
diff --git a/tests/unit_tests/repobee/plugin_tests/test_configwizard.py b/tests/unit_tests/repobee/plugin_tests/test_configwizard.py
index 8d7a3c2..07ec73a 100644
--- a/tests/unit_tests/repobee/plugin_tests/test_configwizard.py
+++ b/tests/unit_tests/repobee/plugin_tests/test_configwizard.py
@@ -23,13 +23,6 @@ def defaults_options():
     )
 
 
-def test_ext_command_does_not_require_api():
-    ext_command = configwizard.Wizard(
-        "config-wizard"
-    ).create_extension_command()
-    assert not ext_command.requires_api
-
-
 def test_exits_when_config_file_exists_and_user_enters_no(config_mock):
     """If the config file exists, a prompt should appear, and if the user
     enters anything but 'yes' the function should exit and the config file
diff --git a/tests/unit_tests/repobee/test_cli.py b/tests/unit_tests/repobee/test_cli.py
index 0ba9ace..39a842d 100644
--- a/tests/unit_tests/repobee/test_cli.py
+++ b/tests/unit_tests/repobee/test_cli.py
@@ -489,7 +489,6 @@ class TestBaseParsing:
                     "-h",
                 ],
                 show_all_opts=True,
-                ext_commands=[],
             )
 
         captured = capsys.readouterr()
@@ -511,7 +510,6 @@ class TestBaseParsing:
                     "-h",
                 ],
                 show_all_opts=False,
-                ext_commands=[],
             )
 
         captured = capsys.readouterr()
@@ -737,180 +735,6 @@ class TestBaseParsing:
         assert parsed_args.master_repo_urls is None
 
 
-class TestExtensionCommands:
-    """Parsing and dispatch tests for extension commands."""
-
-    @pytest.fixture
-    def mock_callback(self):
-        """Return a mock callback function for use with an extension
-        command.
-        """
-        result = plug.Result(
-            name="mock", msg="Hello!", status=plug.Status.SUCCESS
-        )
-        yield mock.MagicMock(
-            spec=_repobee.ext.defaults.configwizard.callback,
-            return_value=result,
-        )
-
-    @pytest.fixture
-    def ext_command(self, mock_callback):
-        """Return a test extension command with an empty parser and a mocked
-        callback.
-        """
-        return plug.ExtensionCommand(
-            parser=plug.ExtensionParser(),
-            name="test-command",
-            help="help",
-            description="description",
-            callback=mock_callback,
-        )
-
-    @pytest.fixture
-    def parsed_base_args_dict(self):
-        return dict(
-            base_url=BASE_URL,
-            user=USER,
-            org_name=ORG_NAME,
-            token=TOKEN,
-            traceback=False,
-        )
-
-    def test_parse_ext_command_that_does_not_require_api(
-        self, ext_command, api_class_mock
-    ):
-        """If an extension command called does not require the API, then the
-        command should not require the API base arguments, and no API should be
-        created.
-        """
-        option = "--test-option"
-        ext_command.parser.add_argument(option, action="store_true")
-
-        parsed_args, api = _repobee.cli.parsing.handle_args(
-            [ext_command.name, option], ext_commands=[ext_command]
-        )
-
-        assert api is None
-        assert parsed_args == argparse.Namespace(
-            category=ext_command.name,
-            action=ext_command.name,
-            _extension_command=ext_command,
-            test_option=True,
-            traceback=False,
-        )
-
-    def test_parse_ext_command_that_requires_api(
-        self,
-        ext_command,
-        api_class_mock,
-        api_instance_mock,
-        parsed_base_args_dict,
-    ):
-        """If an extension command called requires the API, then the command
-        should automatically get the requisite API arguments added to it
-        """
-        ext_command = plug.ExtensionCommand(
-            *ext_command[: len(ext_command) - 3], requires_api=True
-        )
-        option = "--test-option"
-        ext_command.parser.add_argument(
-            option, action="store_true", required=True
-        )
-
-        parsed_args, api = _repobee.cli.parsing.handle_args(
-            [ext_command.name, *BASE_ARGS, option], ext_commands=[ext_command]
-        )
-
-        assert api is api_instance_mock
-        api_class_mock.assert_called_once_with(BASE_URL, TOKEN, ORG_NAME, USER)
-        assert parsed_args == argparse.Namespace(
-            category=ext_command.name,
-            action=ext_command.name,
-            test_option=True,
-            _extension_command=ext_command,
-            **parsed_base_args_dict
-        )
-
-    def test_dispatch_ext_command_that_does_not_require_api(
-        self, ext_command, mock_callback
-    ):
-        """The callback function should get None for the api argument, as it
-        does not require the API.
-        """
-        option = "--test-option"
-        ext_command.parser.add_argument(
-            option, action="store_true", required=True
-        )
-        parsed_args = argparse.Namespace(
-            action=ext_command.name,
-            _extension_command=ext_command,
-            test_option=True,
-            traceback=False,
-        )
-
-        _repobee.cli.dispatch.dispatch_command(
-            parsed_args, None, EMPTY_PATH, [ext_command]
-        )
-
-        calls = mock_callback.mock_calls
-        assert len(calls) == 1
-        assert calls[0] == mock.call(None)
-
-    def test_dispatch_ext_command_that_requires_api(
-        self,
-        ext_command,
-        mock_callback,
-        api_instance_mock,
-        parsed_base_args_dict,
-    ):
-        """The callback function should get ant api instance for the api
-        argument, as it requires the API.
-        """
-
-        option = "--test-option"
-        ext_command = plug.ExtensionCommand(
-            *ext_command[: len(ext_command) - 3], requires_api=True
-        )
-        ext_command.parser.add_argument(
-            option, action="store_true", required=True
-        )
-        parsed_args = argparse.Namespace(
-            action=ext_command.name,
-            _extension_command=ext_command,
-            test_option=True,
-            **parsed_base_args_dict
-        )
-
-        _repobee.cli.dispatch.dispatch_command(
-            parsed_args, api_instance_mock, EMPTY_PATH, [ext_command]
-        )
-
-        calls = mock_callback.mock_calls
-        assert len(calls) == 1
-        assert calls[0] == mock.call(api_instance_mock)
-
-    def test_parse_ext_command_that_requires_base_parsers(self):
-        """The query command requires the students and repo names parsers."""
-        query_command = _repobee.ext.query.create_extension_command()
-        args = [
-            query_command.name,
-            "--hook-results-file",
-            "some/results/file.json",
-            "--mn",
-            *REPO_NAMES,
-            "-s",
-            *STUDENTS_STRING.split(),
-        ]
-
-        parsed_args, api = _repobee.cli.parsing.handle_args(
-            args, ext_commands=[query_command]
-        )
-
-        assert api is None
-        assert parsed_args.master_repo_names == list(REPO_NAMES)
-        assert sorted(parsed_args.students) == sorted(STUDENTS)
-
-
 class TestStudentParsing:
     """Tests for the parsers that use the `--students` and `--students-file`
     arguments.
diff --git a/tests/unit_tests/repobee/test_main.py b/tests/unit_tests/repobee/test_main.py
index 14e30de..9c707d9 100644
--- a/tests/unit_tests/repobee/test_main.py
+++ b/tests/unit_tests/repobee/test_main.py
@@ -3,7 +3,7 @@ import argparse
 import tempfile
 import pathlib
 import types
-from unittest.mock import MagicMock, patch, call, ANY
+from unittest.mock import MagicMock, patch, call
 from collections import namedtuple
 
 import pytest
@@ -18,7 +18,6 @@ import _repobee.constants
 import repobee_plug.cli
 from _repobee import main
 from _repobee import plugin
-from _repobee.ext.defaults import configwizard
 
 import constants
 
@@ -46,9 +45,6 @@ CLONE_ARGS = "clone --mn week-2 -s slarse".split()
 
 module = namedtuple("module", ("name",))
 
-DEFAULT_EXT_COMMANDS = [
-    configwizard.Wizard("config-wizard").create_extension_command()
-]
 DEFAULT_PLUGIN_NAMES = plugin.get_qualified_module_names(_repobee.ext.defaults)
 
 
@@ -105,14 +101,10 @@ def test_happy_path(
     handle_args_mock.assert_called_once_with(
         sys_args[1:],
         show_all_opts=False,
-        ext_commands=[ANY] * len(DEFAULT_EXT_COMMANDS),
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
     dispatch_command_mock.assert_called_once_with(
-        PARSED_ARGS,
-        api_instance_mock,
-        _repobee.constants.DEFAULT_CONFIG_FILE,
-        [ANY] * len(DEFAULT_EXT_COMMANDS),
+        PARSED_ARGS, api_instance_mock, _repobee.constants.DEFAULT_CONFIG_FILE
     )
 
 
@@ -130,7 +122,6 @@ def test_exit_status_1_on_exception_in_parsing(
     handle_args_mock.assert_called_once_with(
         sys_args[1:],
         show_all_opts=False,
-        ext_commands=[ANY] * len(DEFAULT_EXT_COMMANDS),
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
     assert not dispatch_command_mock.called
@@ -151,7 +142,6 @@ def test_exit_status_1_on_exception_in_handling_parsed_args(
     handle_args_mock.assert_called_once_with(
         sys_args[1:],
         show_all_opts=False,
-        ext_commands=[ANY] * len(DEFAULT_EXT_COMMANDS),
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -174,7 +164,6 @@ def test_plugins_args(
     handle_args_mock.assert_called_once_with(
         CLONE_ARGS,
         show_all_opts=False,
-        ext_commands=[],
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -195,7 +184,6 @@ def test_no_plugins_arg(
     handle_args_mock.assert_called_once_with(
         CLONE_ARGS,
         show_all_opts=False,
-        ext_commands=[],
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -216,7 +204,6 @@ def test_no_plugins_with_configured_plugins(
     handle_args_mock.assert_called_once_with(
         CLONE_ARGS,
         show_all_opts=False,
-        ext_commands=[],
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -238,7 +225,6 @@ def test_configured_plugins_are_loaded(
     handle_args_mock.assert_called_once_with(
         CLONE_ARGS,
         show_all_opts=False,
-        ext_commands=[],
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -280,7 +266,6 @@ def test_plugin_with_subparser_name(
     handle_args_mock.assert_called_once_with(
         CLONE_ARGS,
         show_all_opts=False,
-        ext_commands=[],
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -363,7 +348,6 @@ def test_logs_traceback_on_exception_in_dispatch_if_traceback(
     handle_args_mock.assert_called_once_with(
         [*CLONE_ARGS, "--traceback"],
         show_all_opts=False,
-        ext_commands=[ANY] * len(DEFAULT_EXT_COMMANDS),
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
 
@@ -397,7 +381,6 @@ def test_show_all_opts_correctly_separated(
     handle_args_mock.assert_called_once_with(
         [*repobee_plug.cli.CoreCommand.repos.setup.as_name_tuple(), "-h"],
         show_all_opts=True,
-        ext_commands=[ANY] * len(DEFAULT_EXT_COMMANDS),
         config_file=_repobee.constants.DEFAULT_CONFIG_FILE,
     )
     parse_preparser_options_mock.assert_called_once_with(
@@ -442,6 +425,9 @@ user = some-unlikely-user
     show_config_mock.assert_called_once_with(config_file)
 
 
+workdir_category = plug.cli.category("workdir", ["workdir"])
+
+
 class TestRun:
     """Tests for the run function."""
 
@@ -455,9 +441,15 @@ class TestRun:
             )
 
     def test_operates_in_current_workdir_by_default(self):
-        results = main.run(["workdir"], plugins=[self.Workdir])
-        assert results["workdir"][0].data["cwd"] == os.getcwd()
+        results, *_ = list(
+            main.run(["workdir"], plugins=[self.Workdir]).values()
+        )[0]
+        assert results.data["cwd"] == os.getcwd()
 
     def test_operates_in_specified_workdir(self, tmpdir):
-        results = main.run(["workdir"], plugins=[self.Workdir], workdir=tmpdir)
-        assert results["workdir"][0].data["cwd"] == str(tmpdir)
+        results, *_ = list(
+            main.run(
+                ["workdir"], plugins=[self.Workdir], workdir=str(tmpdir)
+            ).values()
+        )[0]
+        assert results.data["cwd"] == str(tmpdir)
diff --git a/tests/unit_tests/repobee_plug/test_containers.py b/tests/unit_tests/repobee_plug/test_containers.py
index 2dca204..99d1385 100644
--- a/tests/unit_tests/repobee_plug/test_containers.py
+++ b/tests/unit_tests/repobee_plug/test_containers.py
@@ -1,81 +1,4 @@
-import argparse
-import pytest
 from repobee_plug import _containers
-from repobee_plug import _exceptions
-
-
-class TestExtensionCommand:
-    def test_raises_if_parser_is_not_a_ExtensionParser(self):
-        parser = argparse.ArgumentParser()
-
-        with pytest.raises(_exceptions.ExtensionCommandError) as exc_info:
-            _containers.ExtensionCommand(
-                parser,
-                "test-command",
-                "help",
-                "description",
-                lambda args, api: None,
-            )
-
-        assert "parser must be a ExtensionParser" in str(exc_info.value)
-
-    def test_requires_api_false_by_default(self):
-        exc_command = _containers.ExtensionCommand(
-            _containers.ExtensionParser(),
-            "test-command",
-            "help",
-            "description",
-            lambda args, api: None,
-        )
-
-        assert not exc_command.requires_api
-
-    def test_eq_with_unequal_parsers(self):
-        """ExtensionCommands should compare equal if all attributes but the
-        parser are the same. The reason for this is that
-        argparse.ArgumentParser instances don't compare equal even if they are.
-        """
-        lhs_parser = _containers.ExtensionParser()
-        rhs_parser = _containers.ExtensionParser()
-        assert lhs_parser != rhs_parser, "parsers should be unequal"
-        command_name = "test-command"
-        help = "help"
-        description = "description"
-
-        def callback(args, api):
-            return None
-
-        lhs = _containers.ExtensionCommand(
-            lhs_parser, command_name, help, description, callback
-        )
-        rhs = _containers.ExtensionCommand(
-            lhs_parser, command_name, help, description, callback
-        )
-
-        assert lhs == rhs
-
-    def test_requires_api_false_incompatible_with_discovery_parser(self):
-        """Test that requires_api=False is incompatible with requesting the
-        discovery parser.
-        """
-        parser = _containers.ExtensionParser()
-
-        def callback(args, api):
-            return None
-
-        with pytest.raises(_exceptions.ExtensionCommandError) as exc_info:
-            _containers.ExtensionCommand(
-                parser=parser,
-                name="test",
-                description="test",
-                help="help",
-                callback=callback,
-                requires_base_parsers=[_containers.BaseParser.REPO_DISCOVERY],
-                requires_api=False,
-            )
-
-        assert "REPO_DISCOVERY" in str(exc_info.value)
-        assert "requires_api" in str(exc_info.value)
 
 
 def test_hook_result_deprecation():
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 54c8cbc..37461d4 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -7,7 +7,6 @@ import repobee_plug as plug
 
 from repobee_plug import _pluginmeta
 from repobee_plug import _exceptions
-from repobee_plug.cli import categorization
 
 import repobee
 
@@ -65,9 +64,6 @@ class TestPluginInheritance:
             def api_init_requires(self):
                 pass
 
-            def create_extension_command(self):
-                pass
-
     def test_with_private_methods(self):
         """Private methods should be able to have any names."""
 
@@ -101,9 +97,6 @@ class TestPluginInheritance:
             def api_init_requires(self):
                 pass
 
-            def create_extension_command(self):
-                pass
-
             def _some_method(self, x, y):
                 return x + y
 
@@ -129,63 +122,26 @@ class TestDeclarativeExtensionCommand:
 
         return Greeting
 
-    def test_defaults(self, basic_greeting_command):
+    def test_default_settings(self, basic_greeting_command):
         """Test declaring an command with no explicit metadata, and checking
         that the defaults are as expected.
         """
         plugin_instance = basic_greeting_command("greeting")
-        ext_cmd = plugin_instance.create_extension_command()
-
-        assert callable(ext_cmd.parser)
-        assert ext_cmd.name == basic_greeting_command.__name__.lower()
-        assert ext_cmd.help == ""
-        assert ext_cmd.description == ""
-        assert ext_cmd.callback == plugin_instance.command
-        assert ext_cmd.requires_api is False
-        assert ext_cmd.requires_base_parsers is None
-        assert ext_cmd.category is None
-
-    def test_with_settings(self):
-        """Test declaring an command with settings."""
-        expected_category = plug.cli.CoreCommand.config
-        expected_name = "cool-greetings"
-        expected_help = "This is a greeting command!"
-        expected_description = "This is a greeting"
-        expected_base_parsers = [
-            plug.BaseParser.REPO_NAMES,
-            plug.BaseParser.MASTER_ORG,
-        ]
-        expected_requires_api = True
-
-        class ExtCommand(plug.Plugin, plug.cli.Command):
-            __settings__ = plug.cli.command_settings(
-                category=expected_category,
-                action=expected_name,
-                help=expected_help,
-                description=expected_description,
-                base_parsers=expected_base_parsers,
-                requires_api=expected_requires_api,
-            )
-
-            def command(self, api):
-                pass
+        settings = plugin_instance.__settings__
 
-        plugin_instance = ExtCommand("greeting")
-        ext_cmd = plugin_instance.create_extension_command()
-
-        assert callable(ext_cmd.parser)
-        assert ext_cmd.name == expected_name
-        assert ext_cmd.help == expected_help
-        assert ext_cmd.description == expected_description
-        assert ext_cmd.callback == plugin_instance.command
-        assert ext_cmd.requires_api == expected_requires_api
-        assert ext_cmd.requires_base_parsers == expected_base_parsers
+        assert settings.help == ""
+        assert settings.description == ""
+        assert settings.requires_api is False
+        assert settings.base_parsers is None
+        assert settings.category is None
 
     def test_generated_parser(self, basic_greeting_command):
         """Test the parser that's generated automatically."""
-        ext_cmd = basic_greeting_command("greeting").create_extension_command()
+        plugin_instance = basic_greeting_command("g")
         parser = argparse.ArgumentParser()
-        ext_cmd.parser(config={}, show_all_opts=True, parser=parser)
+        plugin_instance.attach_options(
+            config={}, show_all_opts=False, parser=parser
+        )
         args = parser.parse_args("--name Eve".split())
 
         assert args.name == "Eve"
@@ -205,9 +161,11 @@ class TestDeclarativeExtensionCommand:
         plugin_name = "greeting"
         configured_name = "Alice"
         config = {plugin_name: {"name": configured_name}}
-        ext_cmd = Greeting("greeting").create_extension_command()
+        plugin_instance = Greeting("greeting")
         parser = argparse.ArgumentParser()
-        ext_cmd.parser(config=config, show_all_opts=False, parser=parser)
+        plugin_instance.attach_options(
+            config=config, show_all_opts=False, parser=parser
+        )
         args = parser.parse_args([])
 
         assert args.name == configured_name
@@ -227,13 +185,12 @@ class TestDeclarativeExtensionCommand:
         plugin_name = "greeting"
         configured_name = "Alice"
         config = {plugin_name: {"name": configured_name}}
-        ext_cmd = Greeting("greeting").create_extension_command()
+        plugin_instance = Greeting(plugin_name)
+        parser = argparse.ArgumentParser()
 
         with pytest.raises(plug.PlugError) as exc_info:
-            ext_cmd.parser(
-                config=config,
-                show_all_opts=False,
-                parser=argparse.ArgumentParser(),
+            plugin_instance.attach_options(
+                config=config, show_all_opts=False, parser=parser
             )
 
         assert (
@@ -257,9 +214,11 @@ class TestDeclarativeExtensionCommand:
             def command(self, api):
                 pass
 
-        ext_cmd = Greeting("g").create_extension_command()
+        plugin_instance = Greeting("g")
         parser = argparse.ArgumentParser()
-        ext_cmd.parser(config={}, show_all_opts=False, parser=parser)
+        plugin_instance.attach_options(
+            config={}, show_all_opts=False, parser=parser
+        )
         name = "Alice"
 
         short_opt_args = parser.parse_args(f"-n {name}".split())
@@ -276,9 +235,11 @@ class TestDeclarativeExtensionCommand:
             def command(self, api):
                 pass
 
-        ext_cmd = Greeting("g").create_extension_command()
+        plugin_instance = Greeting("g")
         parser = argparse.ArgumentParser()
-        ext_cmd.parser(config={}, show_all_opts=False, parser=parser)
+        plugin_instance.attach_options(
+            config={}, show_all_opts=False, parser=parser
+        )
 
         name = "Alice"
         age = 33
@@ -304,9 +265,11 @@ class TestDeclarativeExtensionCommand:
             def command(self, api):
                 pass
 
-        ext_cmd = Greeting("g").create_extension_command()
+        plugin_instance = Greeting("g")
         parser = argparse.ArgumentParser()
-        ext_cmd.parser(config={}, show_all_opts=False, parser=parser)
+        plugin_instance.attach_options(
+            config={}, show_all_opts=False, parser=parser
+        )
 
         with pytest.raises(SystemExit):
             parser.parse_args("--age 12 --old".split())
@@ -346,9 +309,11 @@ class TestDeclarativeExtensionCommand:
             def command(self, api):
                 pass
 
-        ext_cmd = Greeting("g").create_extension_command()
+        plugin_instance = Greeting("g")
         parser = argparse.ArgumentParser()
-        ext_cmd.parser(config={}, show_all_opts=False, parser=parser)
+        plugin_instance.attach_options(
+            config={}, show_all_opts=False, parser=parser
+        )
 
         parsed_args = parser.parse_args(["--old"])
 
@@ -357,11 +322,10 @@ class TestDeclarativeExtensionCommand:
     def test_create_new_category(self):
         """Test that command can be added to a new category."""
 
-        class Greetings(categorization.Category):
-            hello: categorization.Action
+        category = plug.cli.category("greetings", action_names=["hello"])
 
         class Hello(plug.Plugin, plug.cli.Command):
-            __settings__ = plug.cli.command_settings(action=Greetings().hello)
+            __settings__ = plug.cli.command_settings(action=category.hello)
             name = plug.cli.positional()
             age = plug.cli.positional(converter=int)
 
@@ -378,6 +342,7 @@ class TestDeclarativeExtensionCommand:
         results_mapping = repobee.run(
             f"greetings hello {name} {age}".split(), plugins=[Hello]
         )
+        print(results_mapping)
         _, results = list(results_mapping.items())[0]
         result, *_ = results
 
@@ -389,44 +354,34 @@ class TestDeclarativeExtensionCommand:
         category.
         """
 
-        class Greetings(categorization.Category):
-            hello: categorization.Action
-            bye: categorization.Action
-
-        category = Greetings()
+        category = plug.cli.category(
+            name="greetings", action_names=["hello", "bye"]
+        )
+        hello_instance = None
+        bye_instance = None
 
         class Hello(plug.Plugin, plug.cli.Command):
             __settings__ = plug.cli.command_settings(action=category.hello)
             name = plug.cli.positional()
 
             def command(self, api):
-                return plug.Result(
-                    name=self.plugin_name,
-                    msg=f"Hello {self.name}",
-                    status=plug.Status.SUCCESS,
-                )
+                nonlocal hello_instance
+                hello_instance = self
 
         class Bye(plug.Plugin, plug.cli.Command):
             __settings__ = plug.cli.command_settings(action=category.bye)
             name = plug.cli.positional()
 
             def command(self, api):
-                return plug.Result(
-                    name=self.plugin_name,
-                    msg=f"Bye {self.name}",
-                    status=plug.Status.SUCCESS,
-                )
+                nonlocal bye_instance
+                bye_instance = self
 
         name = "Alice"
-        hello_results = repobee.run(
-            f"greetings hello {name}".split(), plugins=[Hello, Bye]
-        )
-        bye_results = repobee.run(
-            f"greetings bye {name}".split(), plugins=[Hello, Bye]
-        )
+        repobee.run(f"greetings hello {name}".split(), plugins=[Hello, Bye])
+        repobee.run(f"greetings bye {name}".split(), plugins=[Hello, Bye])
 
-        assert hello_results[category.hello][0].msg == f"Hello {name}"
-        assert bye_results[category.bye][0].msg == f"Bye {name}"
+        assert hello_instance.name == name
+        assert bye_instance.name == name
 
     def test_raises_when_both_action_and_category_given(self):
         """It is not allowed to give an Action object to the action argument,
@@ -454,6 +409,7 @@ class TestDeclarativeExtensionCommand:
         """Parsed cli arguments should automatically be added to the plugin
         object instance.
         """
+        instance = None
 
         class Ext(plug.Plugin, plug.cli.Command):
             name = plug.cli.option()
@@ -467,18 +423,12 @@ class TestDeclarativeExtensionCommand:
             )
 
             def command(self, api):
-                return plug.Result(
-                    name="ext",
-                    msg="",
-                    status=plug.Status.SUCCESS,
-                    data={"instance": self},
-                )
+                nonlocal instance
+                instance = self
 
         name = "Eve"
         age = 22
-        instance = repobee.run(
-            f"ext {age} --name {name} --high".split(), plugins=[Ext]
-        )["ext"][0].data["instance"]
+        repobee.run(f"ext {age} --name {name} --high".split(), plugins=[Ext])
 
         assert instance.name == name
         assert instance.age == age