codecov[bot]: # [Codecov](https://codecov.io/gh/repobee/repobee/pull/533?src=pr&el=h1) Report
> Merging [#533](https://codecov.io/gh/repobee/repobee/pull/533?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee/commit/b07085bb09f76bd433fb0c1f44346cc693fea792&el=desc) will **increase** coverage by `27.24%`.
> The diff coverage is `100.00%`.

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

```diff
@@             Coverage Diff             @@
##           master     #533       +/-   ##
===========================================
+ Coverage   60.93%   88.18%   +27.24%     
===========================================
  Files          30       47       +17     
  Lines        1938     2463      +525     
  Branches      350      405       +55     
===========================================
+ Hits         1181     2172      +991     
+ Misses        690      241      -449     
+ Partials       67       50       -17     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/533?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/repobee\_plug/cli/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvX19pbml0X18ucHk=) | `100.00% <ø> (ø)` | |
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `97.93% <100.00%> (ø)` | |
| [src/repobee\_plug/cli/args.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvYXJncy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9naXRsYWIucHk=) | `63.86% <0.00%> (-15.33%)` | :arrow_down: |
| [src/repobee\_plug/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fX2luaXRfXy5weQ==) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/\_name.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fbmFtZS5weQ==) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/cli/commandmarkers.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvY29tbWFuZG1hcmtlcnMucHk=) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/\_serialize.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fc2VyaWFsaXplLnB5) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/\_\_version.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fX3ZlcnNpb24ucHk=) | `100.00% <0.00%> (ø)` | |
| ... and [34 more](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree-more) | |

------

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

diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index 94f4cd5..2b40c4f 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -198,7 +198,7 @@ def _add_option(
         else opt.help or ""
     )
 
-    if opt.argument_type == cli.ArgumentType.OPTION:
+    if opt.argument_type in [cli.ArgumentType.OPTION, cli.ArgumentType.FLAG]:
         if opt.short_name:
             args.append(opt.short_name)
 
@@ -208,8 +208,9 @@ def _add_option(
             args.append(f"--{name.replace('_', '-')}")
 
         kwargs["dest"] = name
-        # configured value takes precedence over default
-        kwargs["default"] = configured_value or opt.default
+        if not opt.argument_type == cli.ArgumentType.FLAG:
+            # configured value takes precedence over default
+            kwargs["default"] = configured_value or opt.default
         # required opts become not required if configured
         kwargs["required"] = not configured_value and opt.required
     elif opt.argument_type == cli.ArgumentType.POSITIONAL:
diff --git a/src/repobee_plug/cli/__init__.py b/src/repobee_plug/cli/__init__.py
index 9ed56eb..8887554 100644
--- a/src/repobee_plug/cli/__init__.py
+++ b/src/repobee_plug/cli/__init__.py
@@ -3,6 +3,7 @@ from .categorization import category
 from .args import (
     option,
     positional,
+    flag,
     mutually_exclusive_group,
     ArgumentType,
     is_cli_arg,
@@ -17,6 +18,7 @@ __all__ = [
     "ArgumentType",
     "option",
     "positional",
+    "flag",
     "mutually_exclusive_group",
     "category",
     "command_settings",
diff --git a/src/repobee_plug/cli/args.py b/src/repobee_plug/cli/args.py
index e44431a..6c9d344 100644
--- a/src/repobee_plug/cli/args.py
+++ b/src/repobee_plug/cli/args.py
@@ -5,9 +5,10 @@ from typing import Optional, Any, Callable, Mapping
 
 
 class ArgumentType(enum.Enum):
-    OPTION = "Option"
-    POSITIONAL = "Positional"
-    MUTEX_GROUP = "Mutex"
+    OPTION = "option"
+    POSITIONAL = "positional"
+    MUTEX_GROUP = "mutually_exclusive_group"
+    FLAG = "flag"
 
 
 Option = collections.namedtuple(
@@ -130,8 +131,8 @@ def positional(
 
 
         class Hello(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Positional(help="Your name.")
-            age = plug.cli.Positional(converter=int, help="Your age.")
+            name = plug.cli.positional(help="Your name.")
+            age = plug.cli.positional(converter=int, help="Your age.")
 
             def command(self, api):
                 print(
@@ -165,13 +166,92 @@ def positional(
     )
 
 
+def flag(
+    short_name: Optional[str] = None,
+    long_name: Optional[str] = None,
+    help: str = "",
+    const: Any = True,
+    default: Optional[Any] = None,
+) -> Option:
+    """Create a command line flag for a :py:class:`Command` or a
+    :py:class`CommandExtension`. This is simply a convenience wrapper around
+    :py:func:`option`.
+
+    A flag is specified on the command line as ``--flag``, and causes a
+    constant to be stored. If the flag is omitted, a default value is used
+    instead. The default behavior is that specifying ``--flag``
+    stores the constant ``True``, and omitting it causes it to default to
+    ``False``. It can also be used to store any other form of constant by
+    specifying the ``const`` argument. If so, then omitting the flag will cause
+    it to default to ``None`` instead of ``False``. Finally, the default value
+    can also be overridden by specifying the ``default`` argument.
+
+    Example:
+
+    .. code-block:: python
+        :caption: ext.py
+
+        import repobee_plug as plug
+
+
+        class Flags(plug.Plugin, plug.cli.Command):
+            # a normal flag, which toggles between True and False
+            is_great = plug.cli.flag()
+            # a "reverse" flag which defaults to True instead of False
+            not_great = plug.cli.flag(const=False, default=True)
+            # a flag that stores a constant and defaults to None
+            meaning = plug.cli.flag(const=42)
+            # a flag that stores a constant and defaults to another constant
+            approve = plug.cli.flag(const="yes", default="no")
+
+            def command(self, api):
+                print("is_great", self.is_great)
+                print("not_great", self.not_great)
+                print("meaning", self.meaning)
+                print("approve", self.approve)
+
+    We can then call this command (for example) like so:
+
+        .. code-block:: bash
+
+            $ repobee -p ext.py flags --meaning --not-great
+            is_great False
+            not_great False
+            meaning 42
+            approve no
+
+    Args:
+        short_name: The short name of this option. Must start with ``-``.
+        long_name: The long name of this option. Must start with `--`.
+        help: A description of this option that is used in the CLI help
+            section.
+        const: The constant to store.
+        default: The value to default to if the flag is omitted.
+    Returns:
+        A CLI argument wrapper used internally by RepoBee to create command
+        line argument.
+    """
+    resolved_default = (
+        not const if default is None and isinstance(const, bool) else default
+    )
+    return Option(
+        short_name=short_name,
+        long_name=long_name,
+        help=help,
+        argparse_kwargs=dict(
+            action="store_const", const=const, default=resolved_default
+        ),
+        argument_type=ArgumentType.FLAG,
+    )
+
+
 def mutually_exclusive_group(*, __required__: bool = False, **kwargs):
     """
     Args:
         __required__: Whether or not this mutex group is required.
         kwargs: Keyword arguments on the form ``name=plug.cli.option()``.
     """
-    allowed_types = (ArgumentType.OPTION,)
+    allowed_types = (ArgumentType.OPTION, ArgumentType.FLAG)
 
     def _check_arg_type(name, opt):
         if opt.argument_type not in allowed_types:

Add a flag function to create a CLI flag
Command line flags are very common, so creating a convenience function to create one would be nice. It could possibly also be double as a `store_const` wrapper.
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 37461d4..2e09683 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -256,9 +256,7 @@ class TestDeclarativeExtensionCommand:
         class Greeting(plug.Plugin, plug.cli.Command):
             age_mutex = plug.cli.mutually_exclusive_group(
                 age=plug.cli.option(converter=int),
-                old=plug.cli.option(
-                    argparse_kwargs=dict(action="store_const", const=1337)
-                ),
+                old=plug.cli.flag(const=1337),
                 __required__=True,
             )
 
@@ -285,13 +283,14 @@ class TestDeclarativeExtensionCommand:
         with pytest.raises(ValueError) as exc_info:
             plug.cli.mutually_exclusive_group(
                 age=plug.cli.positional(converter=int),
-                old=plug.cli.option(
-                    argparse_kwargs=dict(action="store_const", const=1337)
-                ),
+                old=plug.cli.flag(const=1337),
                 __required__=True,
             )
 
-        assert "Positional not allowed in mutex group" in str(exc_info.value)
+        assert (
+            f"{plug.cli.ArgumentType.POSITIONAL.value} not allowed in mutex"
+            in str(exc_info.value)
+        )
 
     def test_mutex_group_allows_one_argument(self):
         """Test that a mutex group allows one argument to be specified."""
@@ -300,9 +299,7 @@ class TestDeclarativeExtensionCommand:
         class Greeting(plug.Plugin, plug.cli.Command):
             age_mutex = plug.cli.mutually_exclusive_group(
                 age=plug.cli.option(converter=int),
-                old=plug.cli.option(
-                    argparse_kwargs=dict(action="store_const", const=old)
-                ),
+                old=plug.cli.flag(const=1337),
                 __required__=True,
             )
 
@@ -415,11 +412,7 @@ class TestDeclarativeExtensionCommand:
             name = plug.cli.option()
             age = plug.cli.positional(converter=int)
             tolerance = plug.cli.mutually_exclusive_group(
-                high=plug.cli.option(
-                    argparse_kwargs=dict(action="store_true")
-                ),
-                low=plug.cli.option(argparse_kwargs=dict(action="store_true")),
-                __required__=True,
+                high=plug.cli.flag(), low=plug.cli.flag(), __required__=True,
             )
 
             def command(self, api):

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

name: repobee
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=24.2=py38h06a4308_0
  - python=3.8.20=he870216_0
  - readline=8.2=h5eee18b_0
  - setuptools=75.1.0=py38h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.44.0=py38h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - appdirs==1.4.4
      - bandit==1.7.10
      - black==24.8.0
      - certifi==2025.1.31
      - cffi==1.17.1
      - charset-normalizer==3.4.1
      - click==8.1.8
      - codecov==2.1.13
      - colored==1.4.4
      - coverage==7.6.1
      - cryptography==44.0.2
      - daiquiri==3.2.5.1
      - deprecated==1.2.18
      - exceptiongroup==1.2.2
      - flake8==7.1.2
      - idna==3.10
      - iniconfig==2.1.0
      - markdown-it-py==3.0.0
      - mccabe==0.7.0
      - mdurl==0.1.2
      - mypy-extensions==1.0.0
      - packaging==24.2
      - pathspec==0.12.1
      - pbr==6.1.1
      - platformdirs==4.3.6
      - pluggy==0.13.1
      - pycodestyle==2.12.1
      - pycparser==2.22
      - pyflakes==3.2.0
      - pygithub==2.6.1
      - pygments==2.19.1
      - pyjwt==2.9.0
      - pynacl==1.5.0
      - pytest==7.4.4
      - pytest-cov==5.0.0
      - pytest-mock==3.14.0
      - python-gitlab==1.15.0
      - python-json-logger==3.3.0
      - pyyaml==6.0.2
      - repobee==3.0.0a2
      - requests==2.32.3
      - rich==14.0.0
      - six==1.17.0
      - stevedore==5.3.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - urllib3==2.2.3
      - wrapt==1.17.2
prefix: /opt/conda/envs/repobee

tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_mutex_group_arguments_are_mutually_exclusive tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_positionals_not_allowed_in_mutex_group tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_mutex_group_allows_one_argument tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_parsed_args_are_added_to_self
tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_raises_on_non_hook_public_method tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_happy_path tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_with_private_methods tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_default_settings tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_generated_parser tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_configuration tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_raises_when_non_configurable_value_is_configured tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_override_opt_names tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_positional_arguments tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_create_new_category tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_add_two_actions_to_new_category tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_raises_when_both_action_and_category_given tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_add_required_option_to_config_show tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_raises_when_command_and_command_extension_are_subclassed_together tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_requires_settings tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_requires_non_empty_actions_list
src/repobee_plug/cli/__init__.py src/repobee_plug/_pluginmeta.py src/repobee_plug/cli/args.py
src/repobee_plug/cli/__init__.py src/repobee_plug/_pluginmeta.py src/repobee_plug/cli/args.py